Relying party integration

Add EU Digital Identity Wallet sign-in to your website or app.

Send your users to CodeB with one extra OpenID Connect parameter, acr_values=urn:codeb:acr:eudi-wallet. CodeB asks the user's EU Digital Identity Wallet for a presentation, verifies it, and returns a signed id_token to your application. Your code never handles the wallet protocol: any OpenID Connect library works, and the samples below are tested for Node.js, Python, PHP, Java, Go and ASP.NET Core.

Two rules. 1. Add acr_values=urn:codeb:acr:eudi-wallet to the authorization request: it sends the user straight to the wallet sign-in. 2. Before you grant access, check that the acr claim of the validated id_token is urn:codeb:acr:eudi-wallet or urn:codeb:acr:eudi-wallet-linked. Every sample does both.

How it works

  1. Your app redirects to CodeB. The browser goes to the CodeB authorization endpoint with your client_id, PKCE (S256), state, nonce and acr_values=urn:codeb:acr:eudi-wallet.
  2. CodeB opens the wallet sign-in. On a phone the wallet opens directly; on a desktop the user scans a QR code with the phone that holds the wallet.
  3. The wallet presents, CodeB verifies. CodeB checks the presentation over OpenID for Verifiable Presentations: issuer signature and trust chain, holder binding and, when the credential references one, its revocation status.
  4. CodeB returns an authorization code. The browser comes back to your redirect URI with a code and your state.
  5. Your server exchanges the code. Your back end swaps the code for tokens, validates the id_token and checks the acr claim. The user is signed in.

What you need

Tested samples

Each sample is a complete, runnable application with three routes: /, the login route and /callback. Configuration comes from environment variables (CODEB_ISSUER, CODEB_CLIENT_ID, CODEB_CLIENT_SECRET, APP_BASE_URL).

StackLibraryRecipeDownload
Node.js 18+Express + openid-client 5Openwallet-signin-node.js
Python 3Flask + AuthlibOpenwallet-signin-python.py
PHP 8jumbojett/openid-connect-phpOpenwallet-signin-php.php
Java 17+Spring Boot 3 / Spring Security 6OpenWalletSignInApplication.java
Gogo-oidc v3 + x/oauth2Openwallet-signin-go.go
ASP.NET Core 8Microsoft.AspNetCore.Authentication.OpenIdConnectOpenProgram.cs

How we test them: every sample runs in our test environment against a local OpenID provider that mirrors the CodeB endpoints and token contents, twice: once with a wallet sign-in, which must be accepted, and once with a password sign-in, which must be refused with HTTP 403. The test also checks that acr_values, PKCE S256, state and nonce are sent, and that a callback started in another browser is refused.

Node.js 18+ Express + openid-client 5

Login route: /login. Download wallet-signin-node.js (saves as wallet-signin-node.js)

// Sign in with the EU Digital Identity Wallet - Node.js (Express + openid-client 5) // CodeB acts as the verifier: it asks the user's wallet for a presentation, // verifies it, and hands your app a signed OpenID Connect id_token. // // npm install express@5 express-session@1 openid-client@5 // SESSION_SECRET=<random> CODEB_CLIENT_ID=... CODEB_CLIENT_SECRET=... node wallet-signin-node.js // // Register https://<your-app>/callback as redirect URI for your client first. const express = require('express'); const session = require('express-session'); const { Issuer, generators } = require('openid-client'); const ISSUER = process.env.CODEB_ISSUER || 'https://phone.aloaha.com'; const CLIENT_ID = process.env.CODEB_CLIENT_ID || '<YOUR_CLIENT_ID>'; const CLIENT_SECRET = process.env.CODEB_CLIENT_SECRET || '<YOUR_CLIENT_SECRET>'; const APP_BASE_URL = process.env.APP_BASE_URL || 'http://localhost:3000'; const PORT = Number(process.env.PORT || 3000); const SESSION_SECRET = process.env.SESSION_SECRET; if (!SESSION_SECRET) throw new Error('Set SESSION_SECRET to a long random value'); // The acr values CodeB sets after an EU Digital Identity Wallet sign-in. const WALLET_ACRS = ['urn:codeb:acr:eudi-wallet', 'urn:codeb:acr:eudi-wallet-linked']; const app = express(); app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false })); let client; async function getClient() { if (!client) { const issuer = await Issuer.discover(ISSUER); client = new issuer.Client({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, redirect_uris: [APP_BASE_URL + '/callback'], response_types: ['code'], }); } return client; } app.get('/', (req, res) => { if (req.session.user) return res.send('Signed in as ' + req.session.user.sub + ' (acr=' + req.session.user.acr + ')'); res.send('<a href="/login">Sign in with your EU Digital Identity Wallet</a>'); }); app.get('/login', async (req, res, next) => { try { const c = await getClient(); const verifier = generators.codeVerifier(); req.session.oidc = { verifier, state: generators.state(), nonce: generators.nonce() }; res.redirect(c.authorizationUrl({ scope: 'openid profile email', acr_values: WALLET_ACRS[0], // go straight to the wallet sign-in code_challenge: generators.codeChallenge(verifier), code_challenge_method: 'S256', state: req.session.oidc.state, nonce: req.session.oidc.nonce, })); } catch (e) { next(e); } }); app.get('/callback', async (req, res, next) => { try { const c = await getClient(); const o = req.session.oidc || {}; // Checks state, nonce, signature, issuer, audience and expiry of the id_token. const tokens = await c.callback(APP_BASE_URL + '/callback', c.callbackParams(req), { code_verifier: o.verifier, state: o.state, nonce: o.nonce }); const claims = tokens.claims(); if (!WALLET_ACRS.includes(claims.acr)) { return res.status(403).send('This page needs a sign-in with the EU Digital Identity Wallet (acr=' + claims.acr + ').'); } // New session id after sign-in (no session fixation). req.session.regenerate((err) => { if (err) return next(err); req.session.user = { sub: claims.sub, acr: claims.acr }; res.redirect('/'); }); } catch (e) { next(e); } }); app.listen(PORT, () => console.log('listening on ' + APP_BASE_URL));

Python 3 Flask + Authlib

Login route: /login. Download wallet-signin-python.py (saves as wallet-signin-python.py)

# Sign in with the EU Digital Identity Wallet - Python (Flask + Authlib) # CodeB acts as the verifier: it asks the user's wallet for a presentation, # verifies it, and hands your app a signed OpenID Connect id_token. # # pip install flask authlib requests # SESSION_SECRET=<random> CODEB_CLIENT_ID=... CODEB_CLIENT_SECRET=... python wallet-signin-python.py # # Register https://<your-app>/callback as redirect URI for your client first. import os from flask import Flask, redirect, session, url_for from authlib.integrations.flask_client import OAuth ISSUER = os.environ.get("CODEB_ISSUER", "https://phone.aloaha.com") APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:3000") WALLET_ACRS = ("urn:codeb:acr:eudi-wallet", "urn:codeb:acr:eudi-wallet-linked") app = Flask(__name__) app.secret_key = os.environ["SESSION_SECRET"] # long random value; signs the session cookie oauth = OAuth(app) oauth.register( "codeb", client_id=os.environ.get("CODEB_CLIENT_ID", "<YOUR_CLIENT_ID>"), client_secret=os.environ.get("CODEB_CLIENT_SECRET", "<YOUR_CLIENT_SECRET>"), server_metadata_url=ISSUER + "/.well-known/openid-configuration", client_kwargs={"scope": "openid profile email", "code_challenge_method": "S256"}, ) @app.route("/") def home(): u = session.get("user") if u: return "Signed in as %s (acr=%s)" % (u["sub"], u["acr"]) return '<a href="/login">Sign in with your EU Digital Identity Wallet</a>' @app.route("/login") def login(): # acr_values sends the user straight to the wallet sign-in. return oauth.codeb.authorize_redirect(APP_BASE_URL + "/callback", acr_values=WALLET_ACRS[0]) @app.route("/callback") def callback(): # Checks state, nonce, signature, issuer, audience and expiry of the id_token. token = oauth.codeb.authorize_access_token() claims = token["userinfo"] # the validated id_token claims if claims.get("acr") not in WALLET_ACRS: return "This page needs a sign-in with the EU Digital Identity Wallet (acr=%s)." % claims.get("acr"), 403 session.clear() session["user"] = {"sub": claims["sub"], "acr": claims["acr"]} return redirect(url_for("home")) if __name__ == "__main__": app.run(port=int(os.environ.get("PORT", "3000")))

PHP 8 jumbojett/openid-connect-php

Login route: /login. Download wallet-signin-php.php (saves as wallet-signin-php.php)

<?php // Sign in with the EU Digital Identity Wallet - PHP 8 (jumbojett/openid-connect-php) // CodeB acts as the verifier: it asks the user's wallet for a presentation, // verifies it, and hands your app a signed OpenID Connect id_token. // // composer require jumbojett/openid-connect-php:^1.0 // CODEB_CLIENT_ID=... CODEB_CLIENT_SECRET=... php -S localhost:3000 wallet-signin-php.php // // Register https://<your-app>/callback as redirect URI for your client first. require __DIR__ . '/vendor/autoload.php'; use Jumbojett\OpenIDConnectClient; $issuer = getenv('CODEB_ISSUER') ?: 'https://phone.aloaha.com'; $appBaseUrl = getenv('APP_BASE_URL') ?: 'http://localhost:3000'; $walletAcrs = ['urn:codeb:acr:eudi-wallet', 'urn:codeb:acr:eudi-wallet-linked']; session_start(); $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); if ($path === '/login' || $path === '/callback') { $oidc = new OpenIDConnectClient( $issuer, getenv('CODEB_CLIENT_ID') ?: '<YOUR_CLIENT_ID>', getenv('CODEB_CLIENT_SECRET') ?: '<YOUR_CLIENT_SECRET>' ); $oidc->setRedirectURL($appBaseUrl . '/callback'); $oidc->addScope(['openid', 'profile', 'email']); $oidc->setCodeChallengeMethod('S256'); $oidc->addAuthParam(['acr_values' => $walletAcrs[0]]); // straight to the wallet sign-in // First call redirects to CodeB; on /callback it exchanges the code and checks // state, nonce, signature, issuer, audience and expiry of the id_token. try { $oidc->authenticate(); } catch (\Jumbojett\OpenIDConnectClientException $e) { http_response_code(400); // wrong state, expired code, invalid token ... echo 'Sign-in failed: ' . htmlspecialchars($e->getMessage()); exit; } $acr = $oidc->getVerifiedClaims('acr'); if (!in_array($acr, $walletAcrs, true)) { http_response_code(403); echo 'This page needs a sign-in with the EU Digital Identity Wallet (acr=' . htmlspecialchars((string)$acr) . ').'; exit; } session_regenerate_id(true); $_SESSION['user'] = ['sub' => $oidc->getVerifiedClaims('sub'), 'acr' => $acr]; header('Location: /'); exit; } if (!empty($_SESSION['user'])) { echo 'Signed in as ' . htmlspecialchars($_SESSION['user']['sub']) . ' (acr=' . htmlspecialchars($_SESSION['user']['acr']) . ')'; } else { echo '<a href="/login">Sign in with your EU Digital Identity Wallet</a>'; }

Java 17+ Spring Boot 3 / Spring Security 6

Login route: /oauth2/authorization/codeb. Download WalletSignInApplication.java (saves as WalletSignInApplication.java)

// Sign in with the EU Digital Identity Wallet - Java 17+ (Spring Boot 3, Spring Security 6) // CodeB acts as the verifier: it asks the user's wallet for a presentation, // verifies it, and hands your app a signed OpenID Connect id_token. // // Dependencies (Maven, Spring Boot 3.3 parent): // spring-boot-starter-web, spring-boot-starter-oauth2-client // Save as src/main/java/demo/WalletSignInApplication.java, then // CODEB_CLIENT_ID=... CODEB_CLIENT_SECRET=... mvn spring-boot:run // // Register https://<your-app>/callback as redirect URI for your client first. package demo; import java.util.List; import java.util.Map; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.ClientRegistrations; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @SpringBootApplication @RestController public class WalletSignInApplication { static final List<String> WALLET_ACRS = List.of("urn:codeb:acr:eudi-wallet", "urn:codeb:acr:eudi-wallet-linked"); static String env(String k, String d) { String v = System.getenv(k); return v == null || v.isEmpty() ? d : v; } public static void main(String[] args) { System.setProperty("server.port", env("PORT", "3000")); SpringApplication.run(WalletSignInApplication.class, args); } @Bean ClientRegistrationRepository clients() { ClientRegistration codeb = ClientRegistrations .fromIssuerLocation(env("CODEB_ISSUER", "https://phone.aloaha.com")) .registrationId("codeb") .clientId(env("CODEB_CLIENT_ID", "<YOUR_CLIENT_ID>")) .clientSecret(env("CODEB_CLIENT_SECRET", "<YOUR_CLIENT_SECRET>")) .redirectUri("{baseUrl}/callback") .scope("openid", "profile", "email") .build(); return new InMemoryClientRegistrationRepository(codeb); } // After Spring has validated the id_token (signature, issuer, audience, nonce, expiry): require the wallet acr. @Bean OAuth2UserService<OidcUserRequest, OidcUser> walletOnly() { OidcUserService delegate = new OidcUserService(); return request -> { OidcUser user = delegate.loadUser(request); String acr = user.getIdToken().getClaimAsString("acr"); if (!WALLET_ACRS.contains(acr)) { throw new OAuth2AuthenticationException(new OAuth2Error("insufficient_user_authentication", "This page needs a sign-in with the EU Digital Identity Wallet (acr=" + acr + ").", null)); } return user; }; } @Bean SecurityFilterChain security(HttpSecurity http, ClientRegistrationRepository repo) throws Exception { DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization"); resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce() .andThen(b -> b.additionalParameters(p -> p.put("acr_values", WALLET_ACRS.get(0))))); // straight to the wallet sign-in http.authorizeHttpRequests(a -> a.requestMatchers("/", "/error").permitAll().anyRequest().authenticated()) .oauth2Login(o -> o .authorizationEndpoint(e -> e.authorizationRequestResolver(resolver)) .redirectionEndpoint(r -> r.baseUri("/callback")) .defaultSuccessUrl("/", true) .failureHandler((req, res, ex) -> res.sendError(403, ex.getMessage()))); return http.build(); } @GetMapping("/") String home(@AuthenticationPrincipal OidcUser user) { if (user == null) return "<a href=\"/oauth2/authorization/codeb\">Sign in with your EU Digital Identity Wallet</a>"; return "Signed in as " + user.getSubject() + " (acr=" + user.getIdToken().getClaimAsString("acr") + ")"; } }

Go go-oidc v3 + x/oauth2

Login route: /login. Download wallet-signin-go.go (saves as wallet-signin-go.go)

// Sign in with the EU Digital Identity Wallet - Go (go-oidc v3 + x/oauth2) // CodeB acts as the verifier: it asks the user's wallet for a presentation, // verifies it, and hands your app a signed OpenID Connect id_token. // // go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2 // CODEB_CLIENT_ID=... CODEB_CLIENT_SECRET=... go run wallet-signin-go.go // // Register https://<your-app>/callback as redirect URI for your client first. // Sessions are kept in memory to keep the sample short; use your own session store. package main import ( "context" "crypto/rand" "encoding/base64" "fmt" "html" "log" "net/http" "os" "sync" "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) var walletAcrs = map[string]bool{"urn:codeb:acr:eudi-wallet": true, "urn:codeb:acr:eudi-wallet-linked": true} type pending struct { verifier, nonce string created time.Time } var ( mu sync.Mutex inflight = map[string]pending{} // state -> PKCE verifier + nonce (10 minutes) users = map[string]string{} // session id -> "sub (acr)" ) func env(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } func random() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic(err) } return base64.RawURLEncoding.EncodeToString(b) } func main() { ctx := context.Background() appBase := env("APP_BASE_URL", "http://localhost:3000") provider, err := oidc.NewProvider(ctx, env("CODEB_ISSUER", "https://phone.aloaha.com")) if err != nil { log.Fatal(err) } clientID := env("CODEB_CLIENT_ID", "<YOUR_CLIENT_ID>") conf := oauth2.Config{ ClientID: clientID, ClientSecret: env("CODEB_CLIENT_SECRET", "<YOUR_CLIENT_SECRET>"), Endpoint: provider.Endpoint(), RedirectURL: appBase + "/callback", Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("sid"); err == nil { mu.Lock() u, ok := users[c.Value] mu.Unlock() if ok { fmt.Fprint(w, "Signed in as "+html.EscapeString(u)) return } } fmt.Fprint(w, `<a href="/login">Sign in with your EU Digital Identity Wallet</a>`) }) http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { state, nonce, pkce := random(), random(), oauth2.GenerateVerifier() mu.Lock() for k, v := range inflight { // drop abandoned sign-ins if time.Since(v.created) > 10*time.Minute { delete(inflight, k) } } inflight[state] = pending{pkce, nonce, time.Now()} mu.Unlock() // Bind the state to this browser: the callback only accepts the state from its own cookie. http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: state, Path: "/callback", MaxAge: 600, HttpOnly: true, SameSite: http.SameSiteLaxMode}) http.Redirect(w, r, conf.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(pkce), oauth2.SetAuthURLParam("acr_values", "urn:codeb:acr:eudi-wallet"), // straight to the wallet sign-in ), http.StatusFound) }) http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { state := r.URL.Query().Get("state") c, err := r.Cookie("oidc_state") if err != nil || c.Value != state { http.Error(w, "state does not belong to this browser", http.StatusBadRequest) return } http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: "", Path: "/callback", MaxAge: -1}) mu.Lock() p, ok := inflight[state] delete(inflight, state) mu.Unlock() if !ok || time.Since(p.created) > 10*time.Minute { http.Error(w, "unknown state", http.StatusBadRequest) return } tok, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(p.verifier)) if err != nil { http.Error(w, "token exchange failed", http.StatusBadGateway) return } raw, _ := tok.Extra("id_token").(string) idt, err := verifier.Verify(r.Context(), raw) // signature, issuer, audience, expiry if err != nil || idt.Nonce != p.nonce { http.Error(w, "invalid id_token", http.StatusUnauthorized) return } var claims struct { Acr string `json:"acr"` } if err := idt.Claims(&claims); err != nil || !walletAcrs[claims.Acr] { http.Error(w, "This page needs a sign-in with the EU Digital Identity Wallet (acr="+claims.Acr+").", http.StatusForbidden) return } sid := random() mu.Lock() users[sid] = idt.Subject + " (acr=" + claims.Acr + ")" mu.Unlock() http.SetCookie(w, &http.Cookie{Name: "sid", Value: sid, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) http.Redirect(w, r, "/", http.StatusFound) }) log.Fatal(http.ListenAndServe(":"+env("PORT", "3000"), nil)) }

ASP.NET Core 8 Microsoft.AspNetCore.Authentication.OpenIdConnect

Login route: /login. Download Program.cs (saves as Program.cs)

// Sign in with the EU Digital Identity Wallet - ASP.NET Core 8 (Program.cs) // CodeB acts as the verifier: it asks the user's wallet for a presentation, // verifies it, and hands your app a signed OpenID Connect id_token. // // dotnet new web -n WalletSignIn && cd WalletSignIn // dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect --version 8.0.* // (replace Program.cs with this file) // CODEB_CLIENT_ID=... CODEB_CLIENT_SECRET=... dotnet run // // Register https://<your-app>/callback as redirect URI for your client first. using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; var walletAcrs = new[] { "urn:codeb:acr:eudi-wallet", "urn:codeb:acr:eudi-wallet-linked" }; string Env(string k, string d) => Environment.GetEnvironmentVariable(k) is { Length: > 0 } v ? v : d; var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(o => { o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect(o => { o.Authority = Env("CODEB_ISSUER", "https://phone.aloaha.com"); o.ClientId = Env("CODEB_CLIENT_ID", "<YOUR_CLIENT_ID>"); o.ClientSecret = Env("CODEB_CLIENT_SECRET", "<YOUR_CLIENT_SECRET>"); o.CallbackPath = "/callback"; o.ResponseType = "code"; // authorization code flow, PKCE is on by default o.Scope.Clear(); o.Scope.Add("openid"); o.Scope.Add("profile"); o.Scope.Add("email"); o.MapInboundClaims = false; // keep the original claim names (sub, acr) o.ClaimActions.Remove("acr"); // the handler drops acr from the sign-in cookie by default; keep it o.Events = new OpenIdConnectEvents { // straight to the wallet sign-in OnRedirectToIdentityProvider = ctx => { ctx.ProtocolMessage.AcrValues = walletAcrs[0]; return Task.CompletedTask; }, // the id_token is already validated (signature, issuer, audience, nonce, expiry); now require the wallet acr OnTokenValidated = ctx => { var acr = ctx.Principal?.FindFirst("acr")?.Value; if (acr is null || Array.IndexOf(walletAcrs, acr) < 0) { ctx.HandleResponse(); ctx.Response.StatusCode = 403; return ctx.Response.WriteAsync("This page needs a sign-in with the EU Digital Identity Wallet (acr=" + acr + ")."); } return Task.CompletedTask; }, }; }); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/", (ClaimsPrincipal user) => user.Identity?.IsAuthenticated == true ? Results.Text("Signed in as " + user.FindFirst("sub")?.Value + " (acr=" + user.FindFirst("acr")?.Value + ")") : Results.Content("<a href=\"/login\">Sign in with your EU Digital Identity Wallet</a>", "text/html")); app.MapGet("/login", () => Results.Challenge(new AuthenticationProperties { RedirectUri = "/" }, new[] { OpenIdConnectDefaults.AuthenticationScheme })); app.Run();

What your app receives

The id_token is signed with RS256; the keys are published at /.well-known/jwks.json. After a wallet sign-in it carries:

acrurn:codeb:acr:eudi-wallet: the user signed in with a wallet presentation that CodeB verified against the issuers this tenant trusts. urn:codeb:acr:eudi-wallet-linked: the same, with a wallet the user linked to an existing CodeB account, finished on the same device.
amr["vc"]: the user authenticated with a verifiable credential.
subIdentifier of the CodeB user. A wallet linked to a CodeB account always signs in as that account; otherwise CodeB derives the user from the attributes the wallet discloses.
iss, aud, exp, iat, auth_time, nonceStandard OpenID Connect claims. Your library validates them.
role, groups, preferred_usernameWith the profile (or groups) scope.

Profile data comes from the userinfo endpoint (call it with the access token):

name, given_name, family_name, birthdateFrom the user's CodeB account. Empty profile fields are filled from the wallet's PID when the user signs in. Claims CodeB has no value for are left out.
email, email_verifiedOnly when the account has an address. Rely on it only when email_verified is true.
vc and other wallet attributesOnly the attributes enabled for your client (per-client allowlist). By default no wallet attribute is passed on.

The same flow as raw HTTP

For stacks without an OpenID Connect library. Replace the placeholders; code_challenge is the base64url SHA-256 of your code_verifier.

# 1. Send the browser to the authorization endpoint https://phone.aloaha.com/oauth2/v1/authorize?response_type=code &client_id=<YOUR_CLIENT_ID> &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback &scope=openid%20profile%20email &state=<RANDOM>&nonce=<RANDOM> &code_challenge=<BASE64URL_SHA256_OF_VERIFIER>&code_challenge_method=S256 &acr_values=urn%3Acodeb%3Aacr%3Aeudi-wallet # 2. On https://app.example.com/callback?code=...&state=... exchange the code curl -s https://phone.aloaha.com/oauth2/v1/token \ -u '<YOUR_CLIENT_ID>:<YOUR_CLIENT_SECRET>' \ -d grant_type=authorization_code \ -d code='<CODE>' \ -d redirect_uri=https://app.example.com/callback \ -d code_verifier='<VERIFIER>' # 3. Validate the id_token (RS256, keys at /.well-known/jwks.json): # iss, aud, exp, nonce - then require acr = urn:codeb:acr:eudi-wallet(-linked)

Platforms without code

WordPress, Nextcloud and identity brokers such as Keycloak or Microsoft Entra ID connect to CodeB as a normal OpenID Connect provider:

If the plugin or broker lets you add extra authorization parameters, add acr_values=urn:codeb:acr:eudi-wallet and map the acr claim. Otherwise the user chooses "EU Wallet" on the CodeB sign-in page.

Embedded widget

signin-widget.js runs on pages served by a CodeB tenant, for example the widget demo. On your own domain, use the OpenID Connect flow on this page: it needs no cross-origin calls, and your server receives a signed token rather than a browser callback.

Testing

Use the verifier test page to try a wallet presentation against CodeB, and the EU Wallet sign-in page to see what your users will see. The full verifier protocol is documented in the EU Wallet verifier API.

Frequently asked questions

Do I have to implement OpenID for Verifiable Presentations (OpenID4VP) myself?

No. CodeB is the verifier: it talks to the wallet, checks the presentation and gives your application a standard OpenID Connect id_token. Your code only needs an OpenID Connect client.

How do I make sure the user really signed in with the EU Digital Identity Wallet?

Send acr_values=urn:codeb:acr:eudi-wallet in the authorization request and, after validating the id_token, accept the sign-in only when the acr claim is urn:codeb:acr:eudi-wallet or urn:codeb:acr:eudi-wallet-linked.

Which programming languages are supported?

Any language with an OpenID Connect library. This page has tested samples for Node.js, Python, PHP, Java (Spring Boot), Go and ASP.NET Core, plus the raw HTTP requests.

Which wallets work?

Wallets that present a PID over OpenID for Verifiable Presentations in SD-JWT VC or ISO mdoc format. The verifier checks the issuer's trust chain, holder binding and, when the credential references one, its revocation status.

What does my application receive?

A signed id_token with sub, acr and amr (plus role, groups and preferred_username with the profile scope). Name and e-mail come from the userinfo endpoint.

Can I use the embeddable JavaScript widget on my own domain?

No. The widget runs on pages served by a CodeB tenant. For your own website or app, use the OpenID Connect flow described here.

Does this flow return age_over_18?

No. The sign-in asks the wallet only for the attributes needed to sign the user in; age_over_18 is not one of them.

What happens if the user has no wallet?

The user cannot finish the wallet sign-in. Keep your usual sign-in as an alternative, or tell the user that a wallet is needed.

This page is not endorsed by the European Commission.