Overview
The cross-device flow enables authentication when the user’s Verifiable Credential wallet is on a different device than the one running the application. This is the most common scenario for desktop web applications where users authenticate using a mobile wallet.
The verifier displays a QR code containing an SIOP-2/OIDC4VP connection string that the user scans with their mobile wallet application.
Use Cases
Desktop Web Apps Users access your web app on desktop and scan QR with their mobile wallet
Kiosks & Terminals Public terminals where users authenticate with their personal mobile device
Shared Computers Environments where users don’t want to install software on shared devices
Enhanced Security Separation of authentication device from service access provides additional security
How It Works
Request Login Page
Application redirects user to VCVerifier’s login page at /api/v1/loginQR
QR Code Display
VCVerifier generates and displays a QR code with the OIDC4VP authentication request
User Scans QR
User scans the QR code with their mobile wallet application
Wallet Connects
Wallet extracts the connection parameters and connects to the verifier
User Approves
User reviews the credential request in their wallet and approves
Credential Presentation
Wallet presents the Verifiable Credential to the verifier via OIDC4VP
Verification
VCVerifier verifies the credential against configured trust anchors
Callback
VCVerifier redirects to the application’s callback URL with an authorization code
Token Exchange
Application exchanges the code for a signed JWT
QR Code Contents
The QR code encodes an OpenID Connect connection string with all necessary parameters for the SIOP-2/OIDC4VP flow:
openid://?scope=PacketDeliveryService&response_type=vp_token&response_mode=post&client_id=did:key:z6MktZy7CErCqdLvknH6g9YNVpWupuBNBNovsBrj4DFGn4R1&redirect_uri=http://localhost:3000/verifier/api/v1/authentication_response&state=&nonce=BfEte4DFdlmdO7a_fBiXTw
QR Code Parameters
The type of credential being requested (e.g., “PacketDeliveryService”, “CustomerCredential”)
Set to vp_token to request a Verifiable Presentation
Set to post or direct_post - wallet will POST the response to the verifier
The DID of the verifier acting as the relying party
The endpoint where the wallet should POST the authentication response
Unique session identifier to match responses to requests
Random value to prevent replay attacks
Implementation
Step 1: Redirect to Login Page
Direct users to the VCVerifier login endpoint:
JavaScript
Python (Flask)
HTML Link
function redirectToLogin () {
const state = crypto . randomUUID ();
const callbackUrl = encodeURIComponent ( window . location . origin + '/auth/callback' );
const clientId = 'my-application' ;
sessionStorage . setItem ( 'auth_state' , state );
window . location . href =
`https://verifier.example.com/api/v1/loginQR?` +
`state= ${ state } &` +
`client_callback= ${ callbackUrl } &` +
`client_id= ${ clientId } ` ;
}
Step 2: QR Code Page
VCVerifier displays a page with the QR code. The page is generated from a template that includes:
<! DOCTYPE html >
< html >
< head >
< title > Scan to Login </ title >
< style >
.qr-container {
display : flex ;
flex-direction : column ;
align-items : center ;
justify-content : center ;
min-height : 100 vh ;
}
.qr-code {
width : 300 px ;
height : 300 px ;
border : 2 px solid #333 ;
padding : 20 px ;
}
</ style >
</ head >
< body >
< div class = "qr-container" >
< h1 > Scan to Login </ h1 >
< p > Use your wallet app to scan this QR code </ p >
<!-- QR Code from VCVerifier -->
< img src = "data:{{.qrcode}}" alt = "Login QR Code" class = "qr-code" >
< p class = "instructions" >
1. Open your wallet app < br >
2. Scan this QR code < br >
3. Approve the credential request
</ p >
</ div >
</ body >
</ html >
Step 3: Wallet Scans and Responds
The user’s wallet application:
Scans the QR code
Parses the OpenID Connect URL
Displays credential request to user
Upon approval, POSTs the Verifiable Presentation to the redirect_uri
The wallet sends a POST request to /api/v1/authentication_response:
POST /api/v1/authentication_response?state=OUBlw8wlCZZOcTwRN2wURA HTTP / 1.1
Host : verifier.example.com
Content-Type : application/x-www-form-urlencoded
presentation_submission=ewogICJpZCI6ICJzdHJpbmciLC...&vp_token=ewogICJAY29udGV4dCI6IFs...
Step 4: Handle the Callback
After successful verification, VCVerifier redirects to your client_callback URL:
https://my-app.com/callback?state=274e7465-cc9d-4cad-b75f-190db927e56a&code=IwMTgvY3JlZGVudGlhbHMv
// Callback handler
async function handleAuthCallback () {
const params = new URLSearchParams ( window . location . search );
const code = params . get ( 'code' );
const state = params . get ( 'state' );
const storedState = sessionStorage . getItem ( 'auth_state' );
// Validate state
if ( state !== storedState ) {
throw new Error ( 'Invalid state parameter' );
}
// Exchange code for token
const token = await exchangeCodeForToken ( code );
// Store token and redirect
localStorage . setItem ( 'access_token' , token );
sessionStorage . removeItem ( 'auth_state' );
window . location . href = '/dashboard' ;
}
handleAuthCallback ();
Step 5: Exchange Code for JWT
Exchange the authorization code for the JWT token:
async function exchangeCodeForToken ( code ) {
const params = new URLSearchParams ({
grant_type: 'authorization_code' ,
code: code ,
redirect_uri: window . location . origin + '/auth/callback'
});
const response = await fetch ( 'https://verifier.example.com/token' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/x-www-form-urlencoded' ,
'Accept' : 'application/json'
},
body: params
});
if ( ! response . ok ) {
throw new Error ( 'Token exchange failed' );
}
const data = await response . json ();
return data . access_token ;
}
Response:
{
"token_type" : "Bearer" ,
"expires_in" : 3600 ,
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IldPSEZ1NEhaNTlTTTg1M0M3ZU4wT3ZsS0dyTWVlckRDcEhPVVJvVFF3SHciLCJ0eXAiOiJKV1QifQ.eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVQcmVzZW50YXRpb24iXSwidmVyaWZpYWJsZUNyZWRlbnRpYWwiOlt7InR5cGVzIjpbIlBhY2tldERlbGl2ZXJ5U2VydmljZSIsIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93M2lkLm9yZy9zZWN1cml0eS9zdWl0ZXMvandzLTIwMjAvdjEiXSwiY3JlZGVudGlhbHNTdWJqZWN0Ijp7fSwiYWRkaXRpb25hbFByb3AxIjp7fX1dLCJpZCI6ImViYzZmMWMyIiwiaG9sZGVyIjp7ImlkIjoiZGlkOmtleTp6Nk1rczltOWlmTHd5M0pXcUg0YzU3RWJCUVZTMlNwUkNqZmE3OXdIYjV2V002dmgifSwicHJvb2YiOnsidHlwZSI6Ikpzb25XZWJTaWduYXR1cmUyMDIwIiwiY3JlYXRvciI6ImRpZDprZXk6ejZNa3M5bTlpZkx3eTNKV3FINGM1N0ViQlFWUzJTcFJDamZhNzl3SGI1dldNNnZoIiwiY3JlYXRlZCI6IjIwMjMtMDEtMDZUMDc6NTE6MzZaIiwidmVyaWZpY2F0aW9uTWV0aG9kIjoiZGlkOmtleTp6Nk1rczltOWlmTHd5M0pXcUg0YzU3RWJCUVZTMlNwUkNqZmE3OXdIYjV2V002dmgjejZNa3M5bTlpZkx3eTNKV3FINGM1N0ViQlFWUzJTcFJDamZhNzl3SGI1dldNNnZoIiwiandz IjoiZXlKaU5qUWlPbVpoYkhObExDSmpjbWwwSWpwYkltSTJOQ0pkTENKaGJHY2lPaUpGWkVSVFFTSjkuLjZ4U3FvWmphME53akYwYWY5WmtucXgzQ2JoOUdFTnVuQmY5Qzh1TDJ1bEdmd3VzM1VGTV9abmhQald0SFBsLTcyRTlwM0JUNWYycHRab1lrdE1LcERBIn19.signature"
}
Complete Example
Here’s a complete implementation using vanilla JavaScript:
<! DOCTYPE html >
< html >
< head >
< title > Cross-Device Authentication </ title >
< style >
.login-page {
display : flex ;
justify-content : center ;
align-items : center ;
min-height : 100 vh ;
font-family : Arial , sans-serif ;
}
.login-button {
padding : 15 px 30 px ;
font-size : 18 px ;
background : #0066cc ;
color : white ;
border : none ;
border-radius : 5 px ;
cursor : pointer ;
}
.login-button:hover {
background : #0052a3 ;
}
</ style >
</ head >
< body >
< div class = "login-page" >
< button class = "login-button" onclick = " loginWithQR ()" >
Login with QR Code
</ button >
</ div >
< script >
const VERIFIER_URL = 'https://verifier.example.com' ;
function loginWithQR () {
// Generate unique state
const state = generateUUID ();
sessionStorage . setItem ( 'auth_state' , state );
// Build login URL
const callbackUrl = encodeURIComponent (
window . location . origin + '/callback.html'
);
const loginUrl =
` ${ VERIFIER_URL } /api/v1/loginQR?` +
`state= ${ state } &` +
`client_callback= ${ callbackUrl } &` +
`client_id=my-application` ;
// Redirect to QR page
window . location . href = loginUrl ;
}
function generateUUID () {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' . replace ( / [ xy ] / g , c => {
const r = Math . random () * 16 | 0 ;
const v = c === 'x' ? r : ( r & 0x3 | 0x8 );
return v . toString ( 16 );
});
}
</ script >
</ body >
</ html >
Callback page (callback.html):
<! DOCTYPE html >
< html >
< head >
< title > Authenticating... </ title >
</ head >
< body >
< div style = "text-align: center; margin-top: 50px;" >
< h2 > Authenticating... </ h2 >
< p > Please wait while we complete your login. </ p >
</ div >
< script >
const VERIFIER_URL = 'https://verifier.example.com' ;
async function handleCallback () {
try {
// Get parameters from URL
const params = new URLSearchParams ( window . location . search );
const code = params . get ( 'code' );
const state = params . get ( 'state' );
const storedState = sessionStorage . getItem ( 'auth_state' );
// Validate state
if ( state !== storedState ) {
throw new Error ( 'Invalid state parameter' );
}
// Exchange code for token
const tokenParams = new URLSearchParams ({
grant_type: 'authorization_code' ,
code: code ,
redirect_uri: window . location . origin + '/callback.html'
});
const response = await fetch ( ` ${ VERIFIER_URL } /token` , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/x-www-form-urlencoded' ,
'Accept' : 'application/json'
},
body: tokenParams
});
if ( ! response . ok ) {
throw new Error ( 'Token exchange failed' );
}
const data = await response . json ();
// Store token
localStorage . setItem ( 'access_token' , data . access_token );
sessionStorage . removeItem ( 'auth_state' );
// Redirect to application
window . location . href = '/dashboard.html' ;
} catch ( error ) {
document . body . innerHTML = `
<div style="text-align: center; margin-top: 50px; color: red;">
<h2>Authentication Failed</h2>
<p> ${ error . message } </p>
<a href="/">Try Again</a>
</div>
` ;
}
}
// Execute callback handler
handleCallback ();
</ script >
</ body >
</ html >
Customizing the QR Page
You can customize the QR code display page by providing your own template:
Configuration
Set the template directory in server.yaml:
server :
port : 8080
templateDir : "views/"
staticDir : "views/static/"
Custom Template
Create views/verifier_present_qr.html:
<! DOCTYPE html >
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Login - My Application </ title >
< link rel = "stylesheet" href = "/static/qr-page.css" >
</ head >
< body >
< div class = "container" >
< header >
< img src = "/static/logo.png" alt = "Logo" class = "logo" >
< h1 > Welcome Back </ h1 >
</ header >
< main >
< div class = "qr-section" >
< h2 > Scan to Login </ h2 >
< p class = "instructions" >
Use your wallet app to scan this QR code and authenticate
</ p >
<!-- QR Code - Required Template Variable -->
< div class = "qr-wrapper" >
< img src = "data:{{.qrcode}}" alt = "Authentication QR Code" class = "qr-code" >
</ div >
< div class = "steps" >
< div class = "step" >
< span class = "step-number" > 1 </ span >
< p > Open your wallet app </ p >
</ div >
< div class = "step" >
< span class = "step-number" > 2 </ span >
< p > Scan this QR code </ p >
</ div >
< div class = "step" >
< span class = "step-number" > 3 </ span >
< p > Approve the request </ p >
</ div >
</ div >
</ div >
< div class = "help-section" >
< p > Don't have a wallet yet? </ p >
< a href = "/download-wallet" class = "download-link" > Download Wallet App </ a >
</ div >
</ main >
< footer >
< p > Powered by VCVerifier </ p >
</ footer >
</ div >
< script src = "/static/qr-page.js" ></ script >
</ body >
</ html >
The QR code must be included using <img src="data:{{.qrcode}}">. This is the template variable provided by VCVerifier containing the QR code image data.
Session Management
Session Expiry
Authentication sessions expire after a configured period (default: 30 seconds):
verifier :
sessionExpiry : 30 # seconds
If the user doesn’t complete authentication within this time, they’ll need to refresh the QR code.
Auto-Refresh QR Code
Implement automatic QR code refresh for better UX:
< script >
const SESSION_TIMEOUT = 30000; // 30 seconds
setTimeout(() => {
// Refresh the page to get a new QR code
window . location . reload ();
} , SESSION_TIMEOUT - 5000); // Refresh 5 seconds before expiry
</ script >
Security Considerations
The cross-device flow is secure by design, but follow these best practices:
Best Practices
Always use HTTPS in production to prevent man-in-the-middle attacks
Validate the state parameter in your callback to prevent CSRF
Implement session timeouts to prevent stale QR codes
Display clear instructions to users about which wallet to use
Handle errors gracefully with user-friendly messages
State Validation
Always verify the state parameter matches:
const storedState = sessionStorage . getItem ( 'auth_state' );
if ( receivedState !== storedState ) {
throw new Error ( 'Possible CSRF attack detected' );
}
Troubleshooting
QR Code Not Scanning
Issue: Wallet app cannot scan the QR code.
Solutions:
Ensure QR code is large enough (minimum 200x200px)
Check lighting conditions
Verify QR code is not truncated or distorted
Test with multiple wallet applications
Callback Not Triggered
Issue: Application doesn’t receive the callback after successful authentication.
Solutions:
Verify client_callback URL is accessible from VCVerifier
Check for CORS issues if using different domains
Ensure callback endpoint accepts GET requests
Verify firewall rules allow connections
Session Expired
Issue: User sees “session expired” error.
Solutions:
Increase sessionExpiry configuration
Implement QR code auto-refresh
Add retry mechanism
Display clear timeout warning to users
Next Steps
Same-Device Flow Implement same-device authentication for mobile scenarios
Request Modes Configure different request encoding modes
Frontend Integration Complete frontend integration guide
Configuration Configure VCVerifier for your environment