Skip to main content

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

1

Request Login Page

Application redirects user to VCVerifier’s login page at /api/v1/loginQR
2

QR Code Display

VCVerifier generates and displays a QR code with the OIDC4VP authentication request
3

User Scans QR

User scans the QR code with their mobile wallet application
4

Wallet Connects

Wallet extracts the connection parameters and connects to the verifier
5

User Approves

User reviews the credential request in their wallet and approves
6

Credential Presentation

Wallet presents the Verifiable Credential to the verifier via OIDC4VP
7

Verification

VCVerifier verifies the credential against configured trust anchors
8

Callback

VCVerifier redirects to the application’s callback URL with an authorization code
9

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

scope
string
The type of credential being requested (e.g., “PacketDeliveryService”, “CustomerCredential”)
response_type
string
Set to vp_token to request a Verifiable Presentation
response_mode
string
Set to post or direct_post - wallet will POST the response to the verifier
client_id
string
The DID of the verifier acting as the relying party
redirect_uri
string
The endpoint where the wallet should POST the authentication response
state
string
Unique session identifier to match responses to requests
nonce
string
Random value to prevent replay attacks

Implementation

Step 1: Redirect to Login Page

Direct users to the VCVerifier login endpoint:
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: 100vh;
    }
    .qr-code {
      width: 300px;
      height: 300px;
      border: 2px solid #333;
      padding: 20px;
    }
  </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:
  1. Scans the QR code
  2. Parses the OpenID Connect URL
  3. Displays credential request to user
  4. 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: 100vh;
      font-family: Arial, sans-serif;
    }
    .login-button {
      padding: 15px 30px;
      font-size: 18px;
      background: #0066cc;
      color: white;
      border: none;
      border-radius: 5px;
      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

  1. Always use HTTPS in production to prevent man-in-the-middle attacks
  2. Validate the state parameter in your callback to prevent CSRF
  3. Implement session timeouts to prevent stale QR codes
  4. Display clear instructions to users about which wallet to use
  5. 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

Build docs developers (and LLMs) love