Build passwordless authentication
In this tutorial, you build a passwordless authentication system using the Truvity EUDIW Connector. Instead of usernames and passwords, users prove their identity by presenting an Account Ownership Credential (AOC) from their EUDI Wallet with key binding (device binding)—a proof that the person presenting the credential is the same person it was issued to. No passwords to steal, no phishing attacks to worry about, and no credential databases to protect.
You learn how to:
- Construct a DCQL query that enforces cryptographic holder binding
- Call the connector's management API to create an authentication request
- Verify key binding proof in the callback and extract a stable user identifier
- Recognize returning users across sessions using the
kbKeyId - Manage authenticated sessions tied to wallet credentials
- Extend the flow to support same-device deep links
Estimated time: 30–45 minutes.
What you'll build
You build a backend service with three main components:
- A login endpoint that creates a presentation request for an Account Ownership Credential (AOC) with key binding enforcement, and displays a QR code for the user to scan.
- A callback endpoint that receives the verification result, verifies the key binding proof, and extracts the
kbKeyId—a stable, privacy-preserving identifier derived from the user's wallet key. - A session management layer that binds the
kbKeyIdto a user record, recognizes returning users, and manages session lifecycle.
The user experience looks like this: a customer visits your login page, scans a QR code with their EUDI Wallet, approves the authentication request, and your backend creates an authenticated session within seconds. When the same user returns, your system recognizes them by their kbKeyId without asking for any credentials again—just scan and go.
-
A running connector instance with its public base URL configured and a callback URL pointing to your backend endpoint (for example,
http://localhost:3000/callback/auth). See connector architecture for the deployment model and how callbacks are delivered. -
Access to the internal management API (port 8081)
-
An X.509 access certificate configured in the connector
-
Node.js 18+ development environment (the setup steps use npm)
-
Basic understanding of the EUDI Wallet ecosystem
-
An Account Ownership Credential (AOC) issued to a test wallet. AOC issuance is outside the connector's scope—it happens during account creation when your system (or a trusted issuer) issues an AOC to the user's wallet. See Authentication with EUDI Wallet for how AOCs fit into the ecosystem.
For testing: the EUDIW reference implementation includes a test AOC credential (
urn:eudi:aoc:1) with anaccount_idclaim. The VCT valueurn:eudi:aoc:1is an example used in this tutorial—in production, you define your own VCT for your authentication credential type. If you are using the reference wallet for testing, an AOC is already available. If you do not have access to an AOC issuer, you can follow this tutorial using a PID credential instead—replaceurn:eudi:aoc:1withurn:eudi:pid:1and replace theaccount_idclaim withgiven_nameorfamily_name. The key binding pattern is identical regardless of credential type.
Step 1: Set up your project
Start by creating a new project and installing the dependencies you need. This project is similar to the KYC tutorial, but adds session management libraries because authentication requires tracking logged-in users across requests.
- Shell
mkdir passwordless-auth && cd passwordless-auth
npm init -y
npm install express axios qrcode express-session
npm install -D @types/express @types/qrcode @types/express-session typescript
Here is what each dependency does:
- express / Spring Boot: Hosts the callback endpoint and login pages.
- axios / OkHttp: Calls the connector's management API to create authentication requests.
- qrcode / ZXing: Generates QR codes from the connector's response URI so users can scan with their wallet.
- express-session / Spring Session: Manages authenticated user sessions after successful login. In production, back this with a persistent store like Redis.
You need two in-memory stores: one for tracking login attempts (correlating the connector's state with your login flow) and one for user records (mapping kbKeyId to user accounts). In production, both would be backed by a database.
Your session store needs:
- A
loginSessionsmap keyed by the connector'sstatevalue, storing aLoginSessionobject with fields:sessionId(string),state(string),status(string—starts as"pending"),kbKeyId(optional string),accountId(optional string), andcreatedAt(timestamp). - A
userRecordsmap keyed bykbKeyId, storing aUserRecordobject with fields:kbKeyId(string),accountId(string),displayName(string),firstSeenAt(timestamp),lastLoginAt(timestamp), andloginCount(integer). - A
responseCodeMapfor same-device flow, mapping the connector'sresponseCodeto thestatevalue.
Step 2: Create an authentication request
Now you create a presentation request that asks the user's wallet for an Account Ownership Credential (AOC) with cryptographic key binding. This is the core of passwordless authentication—instead of checking a password, you verify that the user possesses the private key bound to their credential.
An AOC is a credential that links a user's wallet to an account in your system. Think of it as a digital membership card: the credential itself proves the user has an account, and the key binding proof proves the person presenting it is the legitimate holder. AOC is an example credential type used in this tutorial—in production, you would define your own authentication credential type and VCT value. AOC issuance happens outside the connector's scope—typically during account creation, your system (or a trusted issuer) issues an AOC to the user's wallet. For this tutorial, you need an AOC already present in your test wallet. See Authentication with EUDI Wallet for how AOCs fit into the broader ecosystem.
The key difference from the KYC tutorial is the require_cryptographic_holder_binding field. This field defaults to true when omitted, so the connector requests a key binding proof by default. Setting it explicitly to true in the DCQL query makes the requirement visible in your code and ensures it is never accidentally removed. Here is what key binding means in plain terms:
- The wallet signs the presentation with the user's private key.
- The connector verifies this signature and includes the result in the callback.
- You get a
kbKeyId—an RFC 7638 JWK Thumbprint (a deterministic hash of the user's public key) that serves as a stable identifier for that user. - Because the private key never leaves the wallet, no one can impersonate the user, even if they somehow obtained a copy of the credential.
The credential format stays dc+sd-jwt—key binding is controlled by the require_cryptographic_holder_binding field, not by the format string. Note that require_cryptographic_holder_binding is a connector API parameter, not part of the DCQL specification. The connector uses it to decide whether to request a key binding proof from the wallet when constructing the OID4VP authorization request. The field defaults to true, so the connector requests key binding even if you omit it. Setting it explicitly makes the intent clear in your code.
The SD-JWT VC specification calls this mechanism "key binding." The Architecture Reference Framework (ARF) uses "device binding" as the primary term. This tutorial uses "key binding" to align with the connector API parameter name require_cryptographic_holder_binding. See Key binding (device binding) for a detailed explanation.
- cURL
curl -X POST http://connector:8081/oidc4vp \
-H "Content-Type: application/json" \
-d '{
"dcql_query": {
"credentials": [
{
"id": "aoc_auth",
"format": "dc+sd-jwt",
"meta": {
"vct_values": ["urn:eudi:aoc:1"]
},
"claims": [
{ "path": ["account_id"] }
],
"require_cryptographic_holder_binding": true
}
]
}
}'
Example response:
{
"state": "abc123",
"same_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dsame-device",
"cross_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dcross-device"
}
Store the state value to correlate with the callback later. Both URIs use the openid4vp:// scheme with client_id and request_uri query parameters — pass them to the wallet as-is without parsing. The client_id uses the x509_hash prefix followed by a hash of the connector's access certificate. Render cross_device_request_uri as a QR code or redirect the user to same_device_request_uri for same-device flows.
Notice that the request body is simpler than the KYC tutorial—you only request the account_id claim because authentication does not need the user's full identity. You also skip redirect_uri for now (the connector uses sensible defaults). You add redirect_uri when you extend to same-device flow in Step 4.
The most important line is require_cryptographic_holder_binding: true. Although the connector defaults this field to true when omitted, setting it explicitly documents the intent and prevents accidental removal. When key binding is turned off (set to false), the kbKeyId and kbSignatureIsValid fields are absent from the callback payload. For authentication, key binding is essential.
OID4VP includes a nonce parameter in every authorization request to prevent replay attacks. The connector generates this nonce automatically—you do not need to supply one. The wallet includes the nonce in its response, and the connector verifies it before delivering the callback.
Step 3: Implement the callback endpoint
When the user scans the QR code and approves the authentication request, the connector verifies the credential and the key binding proof, then delivers the result to your callback endpoint as a Presented Credentials Event. This is the same asynchronous pattern as the KYC tutorial, but with two additional fields that matter for authentication: kbSignatureIsValid and kbKeyId.
Here is what these fields mean:
kbSignatureIsValid—a boolean that tells you whether the wallet successfully proved possession of the private key bound to the credential. If this istrue, the person presenting the credential is the legitimate holder. If it isfalse, someone may have obtained a copy of the credential without the corresponding private key. Always check this field before granting access.kbKeyId—an RFC 7638 JWK Thumbprint of the holder's public key. This is a deterministic hash computed from the key's mathematical parameters, so the same wallet presenting the same credential always produces the samekbKeyId. You use this as a stable user identifier—it does not change across sessions, and it does not reveal the actual public key.
Your callback handler needs to:
- Verify that
kbSignatureIsValidistrue—reject the login if it is not. - Extract the
kbKeyIdas the user identifier. - Extract the
account_idclaim for additional context. - Find or create a user record bound to the
kbKeyId. - Handle all five statuses, just like in the KYC tutorial.
The credentials and credentialsRaw fields are only present when the status is FULFILLED. For REJECTED, EXPIRED, PROCESSING_ERROR, and VERIFICATION_FAILED, these fields are absent from the payload. The errorDetails field is present only for REJECTED, PROCESSING_ERROR, and VERIFICATION_FAILED.
- cURL
# Simulate a FULFILLED callback with key binding for testing
curl -X POST http://localhost:3000/callback/auth \
-H "Content-Type: application/json" \
-d '{
"status": "FULFILLED",
"state": "abc123",
"responseCode": "RESPONSE_CODE_abc123",
"credentials": {
"aoc_auth": [
{
"issuer": "https://issuer.example.com",
"claims": {
"account_id": "acct_12345"
},
"signatureIsValid": true,
"supportRevocation": false,
"supportTrustAnchor": true,
"isTrusted": false,
"kbKeyId": "JWK_THUMBPRINT_abc123",
"kbSignatureIsValid": true
}
]
},
"credentialsRaw": {
"aoc_auth": [
{
"claims": "eyJhY2NvdW50X2lkIjoiYWNjdF8xMjM0NSJ9",
"issuer": "https://issuer.example.com",
"kbKeyId": "JWK_THUMBPRINT_abc123"
}
]
}
}'
The simulated callback includes several credential verification fields beyond signatureIsValid and kbSignatureIsValid:
supportRevocation—whether the credential includes a Status List Token for revocation checking. Whentrue, theisRevokedfield indicates the revocation status.supportTrustAnchor—whether the credential was signed with an X.509 certificate chain (x5cheader). Whentrue, theisTrustedfield indicates whether the issuer's trust chain is valid.isTrusted—whether the issuer's X.509 trust chain is valid. Only present whensupportTrustAnchoristrue.
The credentialsRaw field contains the same credential data without verification flags: claims as a base64-encoded JSON object, plus issuer, validFrom, validUntil, kbKeyId, and transactionDataHashes. Use credentialsRaw when you need the disclosed attributes in their raw encoded form for forwarding to another system or for audit purposes. See the callback events reference for the complete list of callback statuses.
The key difference from the KYC callback is the key binding verification block. In the KYC tutorial, you check signatureIsValid to confirm the credential is genuine. Here, you also check kbSignatureIsValid to confirm the presenter is the legitimate holder. This two-layer verification—credential authenticity plus holder binding—is what makes passwordless authentication secure.
For the complete list of statuses and error codes, see the callback events reference.
Step 4: Extend to same-device flow
So far, your integration supports the cross-device flow where the user scans a QR code from a desktop browser. But what if the user is already on their mobile device? Scanning a QR code on the same device is awkward. For this scenario, you use the same_device_request_uri as a deep link that opens the wallet app directly.
The same-device flow introduces one additional concept: the responseCode. In the cross-device flow, your backend learns the result through the callback alone. In the same-device flow, the user's browser also needs to know the result so it can display the right page. After the wallet completes the flow, it redirects the user's browser to your redirect_uri with a response_code query parameter. Your backend matches this response_code to the responseCode from the callback to look up the authentication result.
Update the authentication function to return both URIs and include a redirect_uri:
- cURL
curl -X POST http://connector:8081/oidc4vp \
-H "Content-Type: application/json" \
-d '{
"dcql_query": {
"credentials": [
{
"id": "aoc_auth",
"format": "dc+sd-jwt",
"meta": {
"vct_values": ["urn:eudi:aoc:1"]
},
"claims": [
{ "path": ["account_id"] }
],
"require_cryptographic_holder_binding": true
}
]
},
"redirect_uri": "http://localhost:3000/auth-result"
}'
Example response:
{
"state": "abc123",
"same_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dsame-device",
"cross_device_request_uri": "openid4vp://?client_id=x509_hash%3A<certificate_hash>&request_uri=https%3A%2F%2Fconnector.example.com%2Foidc4vp%2Fabc123%2Frequest%3Fflow_type%3Dcross-device"
}
The response is the same as the cross-device request, but now the connector knows to redirect the user's browser to your redirect_uri with a response_code parameter after the wallet completes the flow.
Now add a result endpoint that the browser hits after the wallet redirect. The connector only redirects the browser to your redirect_uri when the presentation succeeds (FULFILLED status in same-device flow). For non-success outcomes (REJECTED, EXPIRED, PROCESSING_ERROR, VERIFICATION_FAILED), the connector does not generate a responseCode and the browser is never redirected—your frontend must handle those cases separately (for example, by polling the login session status or displaying a timeout message after a delay).
This GET /auth-result endpoint reads the response_code query parameter, looks up the corresponding login session via findStateByResponseCode(), and completes the login.
Your auth-result endpoint needs:
- A
GET /auth-resultroute that reads theresponse_codequery parameter from the browser redirect. - A lookup from
response_codeto the originalstatevalue usingfindStateByResponseCode(). - A lookup from
stateto the login session to check the authentication status. - Response logic: if the login session status is
"authenticated", set session cookies (kbKeyId,accountId) and display a success message. If the callback has not arrived yet (status still"pending"), display a brief loading state and retry. The browser only reaches this endpoint on success, so you do not need to handle"declined"or"expired"here—handle those in your frontend polling logic. - The response body can be HTML or plain text—the examples below use plain text for simplicity.
Your frontend decides which flow to use based on the user's device. On desktop, display the QR code. On mobile, render the deep link as a button or redirect to it directly. Both flows use the same callback endpoint—the only difference is how the user initiates the interaction and how the browser learns the result.
Step 5: Manage user sessions
With the callback handler authenticating users, you now need to manage their sessions. The kbKeyId is the anchor for your session management—it is the stable identifier that ties a browser session to a verified wallet holder.
In a traditional password-based system, you store a hashed password and compare it on each login. With wallet-based authentication, the kbKeyId replaces the password hash. The first time a user authenticates, you create a user record keyed by kbKeyId. On subsequent logins, you look up the existing record and update the last login timestamp. The user never needs to remember anything—their wallet handles the cryptographic proof.
Your session management layer needs:
- A
requireAuthmiddleware that checks for a validkbKeyIdin the session and enforces session expiry (for example, 24 hours). - A
createAuthenticatedSessionfunction that stores thekbKeyIdandaccountIdin the session after successful callback verification. - A
destroySessionfunction for logout.
Now add protected routes and a logout endpoint to your app. Create a GET /dashboard route guarded by requireAuth that displays the user's account ID and login count, and a GET /logout route that destroys the session.
Your protected routes need:
- A
GET /dashboardroute guarded byrequireAuthmiddleware that displays the user's account ID, display name, login count, and last login timestamp. - A
GET /logoutroute that destroys the session and redirects to the login page.
A few things to keep in mind about session management:
- The
kbKeyIdis stable as long as the user presents the same credential from the same wallet. If the user re-provisions their wallet or obtains a new credential, thekbKeyIdchanges. Your app should handle this gracefully—for example, by allowing users to link a new wallet key to their existing account through a re-verification flow. - In production, back your session store with Redis, a database, or another persistent store. The in-memory stores in this tutorial are for learning purposes only.
- Set session expiry policies that match your security requirements. A 24-hour session is reasonable for many applications, but high-security scenarios may require shorter sessions or re-authentication for sensitive operations.
Testing your integration
Follow these steps to test the complete passwordless authentication flow end to end.
Start your backend service
- Shell
npx ts-node src/server.ts
Walk through the flow
- Start your backend service and open
http://localhost:3000/login(TypeScript) orhttp://localhost:8080/login(Java) in your browser. - You see a QR code on the login page. Open your test EUDI Wallet app and scan the QR code.
- The wallet displays a consent screen showing the requested credential (AOC) and the
account_idattribute. Review and approve the request. - After approval, the wallet submits the credential with a key binding proof to the connector. The connector verifies both the credential and the key binding proof, then delivers the result to your callback endpoint.
- Check your server logs. You should see either "New user registered" (first login) or "Returning user recognized" (subsequent logins) with the
kbKeyIdand login count. - Navigate to
http://localhost:3000/dashboard(TypeScript) orhttp://localhost:8080/dashboard(Java). You should see your authenticated dashboard with the user's account ID and login count.
Test returning user recognition
- Log out by visiting
/logout. - Go back to
/loginand scan the QR code again with the same wallet. - Check the server logs—you should see "Returning user recognized" with an incremented login count. The
kbKeyIdis the same as the first login because the same wallet key was used.
Test error scenarios
- Reject the request: Scan the QR code but decline the consent screen in the wallet. Your callback receives a
REJECTEDstatus. - Let the session expire: Create an authentication request but do not scan the QR code. After the expiration time passes, your callback receives an
EXPIREDstatus. - Test same-device flow: Open the
/loginpage on a mobile device and tap the "Open in wallet" link. After completing the flow, the wallet redirects your browser to/auth-resultwith theresponse_codeparameter.
What you learned
In this tutorial, you built a complete passwordless authentication system using the EUDIW Connector. Here is what you covered:
- Key binding enforcement: You used the connector API parameter
require_cryptographic_holder_binding: truein the presentation request to explicitly require the wallet to prove possession of the credential's private key. The connector defaults this field totrue, but setting it explicitly documents the intent and prevents credential theft and replay attacks. - Holder binding verification: You checked
kbSignatureIsValidin the callback to confirm the wallet holder is the legitimate credential owner, and rejected authentication attempts where the proof failed. - Stable user identifiers: You extracted the
kbKeyId—an RFC 7638 JWK Thumbprint of the holder's public key—and used it as a stable, privacy-preserving identifier to recognize returning users across sessions. - Passwordless session management: You bound the
kbKeyIdto user records and browser sessions, implementing a complete login flow without passwords, password hashes, or credential databases. - Status handling: You handled all five verification outcomes (
FULFILLED,REJECTED,EXPIRED,PROCESSING_ERROR,VERIFICATION_FAILED) with appropriate responses for each. - Same-device correlation: You used the
responseCodefield to correlate the browser redirect with the callback result in same-device flows. - Security properties: the entire flow is built on the OID4VP protocol under the HAIP profile, providing phishing resistance through signed authorization requests, encrypted credential delivery from wallet to Relying Party, and cryptographic proof of holder identity through key binding.
Next steps
- Build a KYC verification flow—learn how to verify customer identity using PID credentials with the same connector API
- Passwordless authentication how-to guide—a task-oriented reference for authentication integration when you already know the concepts
- Key binding explained—deep dive into how holder binding works in the EUDI ecosystem
- Ephemeral data model—understand how the connector handles credential data without persistent storage
Further reading
- HAIP profile—the High Assurance Interoperability Profile that governs authentication flows
- Key binding—how cryptographic holder binding works under the hood
- Connector architecture—how the connector processes requests and delivers results
- Error codes—wallet-facing HTTP error responses
- Callback events—Presented Credentials Event statuses and payload fields