2026-05-31·5 min read·sota.io Team

OpenID4VP Cross-Device Flow: Building eIDAS 2.0 EUDIW Authentication in Your SaaS 2026

Post #2 in the sota.io EU Digital Identity & eIDAS 2.0 Developer Series

OpenID4VP cross-device flow implementation guide for eIDAS 2.0 EUDIW 2026

Post #1 covered who must accept the EU Digital Identity Wallet and the full ARF v2.4.0 architecture. This post goes one level deeper: how to implement the cross-device flow in practice — the scenario where the user authenticates on a desktop browser by scanning a QR code with their EUDIW-compatible wallet app on their phone.

This is the most common Relying Party integration pattern. Most SaaS platforms show a login screen on desktop; the cross-device flow lets the user prove their identity using their EUDIW wallet without leaving the desktop session.


Cross-Device vs Same-Device Flow

The OpenID for Verifiable Presentations specification (OpenID4VP) defines two fundamental flow modes:

FlowTriggerWhere Response Arrives
Cross-DeviceRP displays a QR code; user scans with wallet on separate deviceRP backend polls or receives redirect from wallet's backchannel
Same-DeviceRP sends a deep-link (openid4vp://) that opens the wallet on the same deviceWallet redirects back to redirect_uri on the same device

Cross-device is your default path for desktop web applications. The user loads your website on a laptop, sees a QR code, opens their EUDIW wallet on their phone, scans the QR, reviews the presentation request, approves it, and your backend receives the verified credential presentation — all without the user leaving the laptop session.

When to use which

This post covers the cross-device implementation only.


Protocol Overview

The cross-device flow involves five parties:

  1. User Agent (browser) — the user's desktop browser loading your SaaS
  2. Relying Party Frontend — your JavaScript running in the browser
  3. Relying Party Backend — your server handling session state and verification
  4. EUDIW Wallet — the user's phone running a certified eIDAS 2.0 wallet app
  5. Trust Framework — the national QTSP infrastructure that verifies issuer certificates

The flow at a glance:

Browser                    RP Backend                    User Wallet
   |                           |                              |
   |--- GET /auth/eudiw ------->|                              |
   |                           |--- Generate Session ID -----> |
   |                           |--- Create Auth Request ------> |
   |<-- QR Code (deep-link) ---|                              |
   |                           |                              |
   |--- Poll /auth/status? --->|                              |
   |    session_id=xxx         |                              |
   |                           |                              |
   |                           |<-- Wallet scans QR, sends -->|
   |                           |    VP Token (POST redirect_uri)|
   |                           |                              |
   |                           |--- Verify VP Token ---------->|
   |                           |--- Check Trust Chain -------->|
   |                           |                              |
   |<-- 200 Authenticated -----|                              |

Step 1: Generate the Authorization Request

When the user clicks "Sign in with EU Digital Identity Wallet", your backend generates an Authorization Request.

The Authorization Request is a signed JWT containing a Presentation Definition — a structured description of which credentials and attributes you need. It must be signed with your Relying Party private key (registered in the EUDIW Trust Framework).

Request parameters

ParameterRequiredDescription
client_idYesYour registered RP URI (must match your Trust Framework registration)
client_id_schemeYes"redirect_uri" for backchannel response
response_typeYes"vp_token"
response_modeYes"direct_post" (wallet POSTs the response to your backend)
redirect_uriYesYour backend endpoint that receives the VP Token
nonceYesCryptographically random, single-use, bound to session
presentation_definitionYesDescribes which credential/attributes to request
stateRecommendedYour session identifier for polling
client_metadataRecommendedYour RP display name, logo URI, legal name

TypeScript implementation

import { SignJWT, generateKeyPair, exportJWK } from 'jose';
import { randomBytes } from 'crypto';

interface SessionState {
  sessionId: string;
  nonce: string;
  status: 'pending' | 'completed' | 'failed' | 'expired';
  vpToken?: string;
  verifiedAttributes?: Record<string, string>;
  createdAt: number;
}

const sessions = new Map<string, SessionState>();

async function generateAuthorizationRequest(redirectUri: string): Promise<{
  requestUri: string;
  sessionId: string;
  qrCodeData: string;
}> {
  const sessionId = randomBytes(16).toString('hex');
  const nonce = randomBytes(32).toString('hex');

  sessions.set(sessionId, {
    sessionId,
    nonce,
    status: 'pending',
    createdAt: Date.now(),
  });

  // Build Presentation Definition requesting PID (Person Identification Data)
  const presentationDefinition = {
    id: `pd-${sessionId}`,
    input_descriptors: [
      {
        id: 'eu-pid',
        name: 'EU Person Identification Data',
        purpose: 'Verify your identity to access this service',
        format: {
          'vc+sd-jwt': {
            'sd-jwt_alg_values': ['ES256'],
          },
        },
        constraints: {
          limit_disclosure: 'required',
          fields: [
            {
              // Request given name
              path: ['$.given_name'],
              filter: { type: 'string' },
            },
            {
              // Request family name
              path: ['$.family_name'],
              filter: { type: 'string' },
            },
            {
              // Request age over 18 (selective disclosure — not full birthdate)
              path: ['$.age_over_18'],
              filter: { type: 'boolean' },
            },
          ],
        },
      },
    ],
  };

  // Sign the authorization request as a JWT
  const requestJwt = await new SignJWT({
    client_id: process.env.EUDIW_CLIENT_ID!,
    client_id_scheme: 'redirect_uri',
    response_type: 'vp_token',
    response_mode: 'direct_post',
    redirect_uri: redirectUri,
    nonce,
    state: sessionId,
    presentation_definition: presentationDefinition,
    client_metadata: {
      client_name: 'Your SaaS Platform',
      logo_uri: 'https://yoursaas.eu/logo.png',
    },
  })
    .setProtectedHeader({ alg: 'ES256', kid: process.env.EUDIW_KEY_ID! })
    .setIssuedAt()
    .setExpirationTime('5m') // Request expires in 5 minutes
    .sign(await importPrivateKey());

  // Store request JWT at a publicly accessible URI
  const requestUri = `${process.env.BASE_URL}/api/eudiw/requests/${sessionId}`;
  // (store requestJwt in Redis or DB, served at requestUri)

  // The QR code encodes the openid4vp:// deep-link
  const deepLink = `openid4vp://?client_id=${encodeURIComponent(
    process.env.EUDIW_CLIENT_ID!
  )}&request_uri=${encodeURIComponent(requestUri)}`;

  return { requestUri, sessionId, qrCodeData: deepLink };
}

Presentation Definition in depth

The input_descriptors array tells the wallet what to present. Key fields:

Common PID claim paths:

ClaimPathType
Given name$.given_namestring
Family name$.family_namestring
Birth date$.birth_datestring (ISO 8601)
Age over 18$.age_over_18boolean
Age over 21$.age_over_21boolean
Nationality$.nationalitystring (ISO 3166-1 α-2)
Residence country$.resident_countrystring
Personal identifier$.personal_administrative_numberstring

Request only what you need. The principle of data minimisation (GDPR Article 5(1)(c)) applies: if you only need to verify the user is over 18, request age_over_18 — not birth_date. Requesting unnecessary attributes will cause wallet UX friction and may expose you to regulatory scrutiny.


Step 2: Display the QR Code

Your frontend polls a session-status endpoint and renders the QR code until the flow completes or expires.

// React component
import QRCode from 'qrcode';
import { useEffect, useState, useCallback } from 'react';

interface EUDIWAuthProps {
  onSuccess: (attributes: Record<string, string>) => void;
  onError: (reason: string) => void;
}

export function EUDIWCrossDeviceAuth({ onSuccess, onError }: EUDIWAuthProps) {
  const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string | null>(null);
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [status, setStatus] = useState<'loading' | 'pending' | 'completed' | 'expired' | 'error'>('loading');

  // Initialise session
  useEffect(() => {
    fetch('/api/eudiw/start', { method: 'POST' })
      .then(r => r.json())
      .then(async ({ sessionId, qrCodeData }) => {
        setSessionId(sessionId);
        const dataUrl = await QRCode.toDataURL(qrCodeData, { width: 300, margin: 1 });
        setQrCodeDataUrl(dataUrl);
        setStatus('pending');
      })
      .catch(() => setStatus('error'));
  }, []);

  // Poll for status
  const checkStatus = useCallback(async () => {
    if (!sessionId || status !== 'pending') return;
    const resp = await fetch(`/api/eudiw/status?session=${sessionId}`);
    const data = await resp.json();
    if (data.status === 'completed') {
      setStatus('completed');
      onSuccess(data.attributes);
    } else if (data.status === 'failed') {
      setStatus('error');
      onError(data.reason);
    } else if (data.status === 'expired') {
      setStatus('expired');
    }
  }, [sessionId, status, onSuccess, onError]);

  useEffect(() => {
    const interval = setInterval(checkStatus, 2000); // poll every 2s
    return () => clearInterval(interval);
  }, [checkStatus]);

  if (status === 'loading') return <p>Initialising...</p>;
  if (status === 'expired') return <p>Session expired. <button onClick={() => window.location.reload()}>Retry</button></p>;
  if (status === 'error') return <p>Authentication failed. Please try again.</p>;
  if (status === 'completed') return <p>Authenticated ✓</p>;

  return (
    <div className="flex flex-col items-center gap-4">
      <p className="text-sm text-zinc-400">Scan with your EU Digital Identity Wallet app</p>
      {qrCodeDataUrl && (
        <img src={qrCodeDataUrl} alt="Scan with EUDIW wallet" className="rounded-lg border border-zinc-700" />
      )}
      <p className="text-xs text-zinc-500">Waiting for wallet response...</p>
    </div>
  );
}

Session state management

Sessions must have a hard expiry. The ARF v2.4.0 recommends a maximum nonce lifetime of 5 minutes for cross-device flows (the user needs time to open their wallet app, find the credential, and approve). Implement cleanup:

// Cleanup expired sessions (run on a cron or after each poll)
function cleanupExpiredSessions() {
  const now = Date.now();
  for (const [id, session] of sessions.entries()) {
    if (now - session.createdAt > 5 * 60 * 1000) {
      sessions.set(id, { ...session, status: 'expired' });
    }
  }
}

In production, store sessions in Redis with a TTL rather than an in-memory Map.


Step 3: Receive the Authorization Response

When the user approves the presentation in their wallet, the wallet POSTs the Authorization Response to your redirect_uri. This is the direct_post response mode.

import express from 'express';
const router = express.Router();

router.post('/api/eudiw/callback', express.urlencoded({ extended: false }), async (req, res) => {
  const { vp_token, presentation_submission, state } = req.body;

  if (!vp_token || !state) {
    return res.status(400).json({ error: 'invalid_request' });
  }

  const session = sessions.get(state as string);
  if (!session || session.status !== 'pending') {
    return res.status(400).json({ error: 'invalid_session' });
  }

  try {
    const verifiedAttributes = await verifyVPToken(vp_token, session.nonce, presentation_submission);
    sessions.set(state, {
      ...session,
      status: 'completed',
      vpToken: vp_token,
      verifiedAttributes,
    });
    // Wallet expects a redirect_uri response — 200 OK with optional redirect
    res.json({ redirect_uri: `${process.env.BASE_URL}/auth/success` });
  } catch (err) {
    sessions.set(state, { ...session, status: 'failed' });
    res.status(400).json({ error: 'verification_failed' });
  }
});

Presentation Submission

The presentation_submission parameter maps the wallet's response back to the input descriptors in your Presentation Definition. It looks like:

{
  "id": "submission-1",
  "definition_id": "pd-<session-id>",
  "descriptor_map": [
    {
      "id": "eu-pid",
      "format": "vc+sd-jwt",
      "path": "$"
    }
  ]
}

Parse and validate this against your original Presentation Definition to confirm the wallet presented credentials matching your request — not substituted credentials.


Step 4: Verify the VP Token

The vp_token is an SD-JWT Verifiable Presentation. Verification has four layers:

Layer 1 — Outer JWT signature (Holder binding)

The wallet signs the outer JWT with the holder's private key, proving they control the credential. Verify using the public key embedded in the VP Token header:

import { jwtVerify, importX509 } from 'jose';

async function verifyHolderBinding(vpToken: string, nonce: string): Promise<void> {
  // The VP Token outer JWT contains the holder's key in the header
  const [headerB64] = vpToken.split('.');
  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());

  if (!header.jwk) throw new Error('Missing holder JWK in header');

  const holderKey = await importPublicKey(header.jwk);
  const { payload } = await jwtVerify(vpToken, holderKey);

  // Validate nonce binding — critical to prevent replay attacks
  if (payload.nonce !== nonce) {
    throw new Error('Nonce mismatch — possible replay attack');
  }
}

Layer 2 — Issuer signature (Trust chain)

Each SD-JWT credential inside the VP Token is signed by the issuing QTSP (the government wallet issuer). Verify against the Trust Framework:

import { importX509, jwtVerify } from 'jose';

async function verifyIssuerSignature(sdJwtCredential: string): Promise<void> {
  // Split SD-JWT into Issuer JWT + Disclosures
  const [issuerJwt, ...disclosures] = sdJwtCredential.split('~');

  const [headerB64] = issuerJwt.split('.');
  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());

  // Fetch issuer certificate from Trust Framework
  // The 'iss' claim in the JWT payload contains the issuer URI
  const { payload: unverifiedPayload } = await jwtDecode(issuerJwt);
  const issuerCertificate = await fetchIssuerCertificate(unverifiedPayload.iss as string);

  // Verify against fetched certificate
  const issuerKey = await importX509(issuerCertificate, 'ES256');
  await jwtVerify(issuerJwt, issuerKey);
}

async function fetchIssuerCertificate(issuerUri: string): Promise<string> {
  // EUDIW Trust Framework: fetch issuer metadata from .well-known/openid-configuration
  const metadataUrl = `${issuerUri}/.well-known/openid-credential-issuer`;
  const resp = await fetch(metadataUrl);
  const metadata = await resp.json();

  // Certificate chain published in JWKS URI
  const jwksResp = await fetch(metadata.jwks_uri);
  const jwks = await jwksResp.json();

  // Return the signing key — in production, cache with short TTL
  return jwks.keys.find((k: any) => k.use === 'sig');
}

Layer 3 — Selective Disclosure (SD-JWT)

Extract and verify the disclosed attributes. An SD-JWT credential contains:

import { createHash } from 'crypto';

interface DisclosedAttributes {
  [key: string]: string | boolean | number;
}

function verifySelectiveDisclosures(sdJwtCredential: string): DisclosedAttributes {
  const parts = sdJwtCredential.split('~').filter(Boolean);
  const [issuerJwt, ...disclosures] = parts;

  // Decode the issuer JWT payload
  const [, payloadB64] = issuerJwt.split('.');
  const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());

  // Build map of hash → disclosure
  const disclosedClaims: DisclosedAttributes = {};

  for (const disclosure of disclosures) {
    // Each disclosure is a base64url-encoded JSON array: [salt, claim_name, claim_value]
    const [salt, claimName, claimValue] = JSON.parse(
      Buffer.from(disclosure, 'base64url').toString()
    );

    // Verify the disclosure hash matches a _sd entry in the payload
    const hash = createHash('sha256')
      .update(disclosure)
      .digest('base64url');

    const sdHashes: string[] = payload._sd || [];
    if (!sdHashes.includes(hash)) {
      throw new Error(`Disclosure hash mismatch for claim: ${claimName}`);
    }

    disclosedClaims[claimName] = claimValue;
  }

  return disclosedClaims;
}

Layer 4 — Presentation Definition compliance

Confirm the disclosed attributes satisfy your original Presentation Definition:

function verifyPresentationCompliance(
  disclosedAttributes: DisclosedAttributes,
  requiredPaths: string[]
): void {
  for (const path of requiredPaths) {
    // Path like '$.given_name' → key 'given_name'
    const key = path.replace('$.', '');
    if (!(key in disclosedAttributes)) {
      throw new Error(`Required attribute missing: ${key}`);
    }
  }
}

Step 5: Map Verified Attributes to Your User Model

After successful verification, you have a disclosedAttributes object. Map it to your application's identity model:

interface EUDIWVerifiedIdentity {
  givenName: string;
  familyName: string;
  isOver18: boolean;
  rawAttributes: Record<string, unknown>;
  verifiedAt: Date;
  issuer: string;
}

function mapToIdentity(
  disclosedAttributes: DisclosedAttributes,
  issuerUri: string
): EUDIWVerifiedIdentity {
  return {
    givenName: disclosedAttributes['given_name'] as string,
    familyName: disclosedAttributes['family_name'] as string,
    isOver18: disclosedAttributes['age_over_18'] as boolean,
    rawAttributes: disclosedAttributes,
    verifiedAt: new Date(),
    issuer: issuerUri,
  };
}

Store the verifiedAt timestamp and issuer alongside the identity data. For compliance, you need to demonstrate when and from which Member State issuer you verified the identity.


Security Requirements

The ARF v2.4.0 and the OpenID4VP specification impose specific security controls for Relying Parties:

Nonce uniqueness and binding

The nonce in the Authorization Request MUST be:

const usedNonces = new Set<string>();

function validateNonce(nonce: string): void {
  if (usedNonces.has(nonce)) {
    throw new Error('Nonce replay detected');
  }
  usedNonces.add(nonce);
  // In production: store used nonces in Redis with TTL = max session lifetime
}

redirect_uri validation

Your redirect_uri must:

function validateRedirectUri(receivedUri: string): void {
  const allowedUri = process.env.EUDIW_REDIRECT_URI!;
  if (receivedUri !== allowedUri) {
    throw new Error('Unexpected redirect_uri');
  }
}

Certificate pinning for Trust Framework

When fetching issuer certificates from the EUDIW Trust Framework, pin the root CA certificate published by the EU Commission. Do not trust arbitrary certificates presented in the VP Token header.

Credential expiry

Check exp and iat claims in the issuer JWT. Reject credentials:

function validateCredentialExpiry(payload: any): void {
  const now = Math.floor(Date.now() / 1000);
  if (payload.exp && payload.exp < now) {
    throw new Error('Credential expired');
  }
  if (payload.iat && now - payload.iat > 365 * 24 * 3600) {
    throw new Error('Credential too old — user may need to re-issue');
  }
}

Error Handling

The wallet will send error responses if the user declines, or if the presentation fails validation on the wallet side.

Error CodeMeaningUser Action
access_deniedUser declined the presentation requestShow "You declined. Try again?"
invalid_requestYour Presentation Definition was malformedDebug your request
vp_formats_not_supportedWallet doesn't support requested formatFallback to alternative auth
insufficient_claimsWallet credential doesn't contain required claimsUser may need to update their PID
router.post('/api/eudiw/callback', async (req, res) => {
  const { error, error_description, state } = req.body;

  if (error) {
    const session = sessions.get(state);
    if (session) {
      sessions.set(state, {
        ...session,
        status: 'failed',
      });
    }
    // Log the error for debugging — do NOT expose error_description to the end user
    console.error(`EUDIW error for session ${state}: ${error} — ${error_description}`);
    return res.json({ redirect_uri: `${process.env.BASE_URL}/auth/failed?reason=${error}` });
  }

  // ... normal flow
});

UX Considerations for Cross-Device Flow

Good UX for the cross-device flow requires handling several states:

Show a countdown timer

The Authorization Request expires in 5 minutes. Show a countdown so the user knows they need to act:

function CountdownTimer({ expiresAt }: { expiresAt: number }) {
  const [remaining, setRemaining] = useState(Math.floor((expiresAt - Date.now()) / 1000));

  useEffect(() => {
    const interval = setInterval(() => {
      setRemaining(Math.floor((expiresAt - Date.now()) / 1000));
    }, 1000);
    return () => clearInterval(interval);
  }, [expiresAt]);

  const minutes = Math.floor(remaining / 60);
  const seconds = remaining % 60;

  return (
    <p className="text-sm text-zinc-400">
      QR code expires in {minutes}:{seconds.toString().padStart(2, '0')}
    </p>
  );
}

Animate the waiting state

While polling, show animated dots or a spinner. Users who are unfamiliar with wallet-based auth may think nothing is happening. A subtle "Waiting for wallet..." indicator reassures them.

Provide wallet download instructions

Many users won't have a EUDIW-compatible wallet installed yet (particularly until December 2026). Show a link to their country's official wallet:

const walletLinks = [
  { country: '🇩🇪 Germany', name: 'Bundeswallet', url: 'https://example.de/wallet' },
  { country: '🇫🇷 France', name: 'FranceConnect+', url: 'https://example.fr/wallet' },
  { country: '🇪🇸 Spain', name: 'Carpeta Ciudadana', url: 'https://example.es/wallet' },
  // ... other Member States
];

Update this list as national EUDIW implementations are certified — the European Commission maintains an official list under the ARF v2.4.0 conformance programme.


Testing with Reference Wallet Implementations

Before December 2026, test against the official reference implementations:

EU Commission Reference Wallet (EUDIW-RI)

The EU Commission provides a reference wallet implementation for testing:

These are the reference implementations maintained under the ARF conformance programme. Loading test PID credentials requires the EUDIW reference issuer — also open source on the same GitHub organisation.

Local testing setup

# Clone reference wallet backend (Spring Boot)
git clone https://github.com/eu-digital-identity-wallet/eudi-srv-web-issuing-eudiw-py
cd eudi-srv-web-issuing-eudiw-py

# Configure test issuer
cp .env.example .env
# Edit .env: set issuer keys, RP trust anchor

# Start issuer
docker compose up -d

# Load test PID into reference wallet app
# (follow wallet app README for test credential issuance)

Verifier backend for local testing

git clone https://github.com/eu-digital-identity-wallet/eudi-srv-web-verifier-endpoint-23220-4-kt
cd eudi-srv-web-verifier-endpoint-23220-4-kt
./gradlew bootRun
# Test endpoint: http://localhost:8080

Implementation Checklist

Use this before deploying cross-device EUDIW authentication to production:

Authorization Request

QR Code Display

VP Token Verification

Error Handling

Session Security


What's Next in This Series

This post covered the cross-device flow — the most common RP integration pattern. Upcoming posts in the EU Digital Identity & eIDAS 2.0 Developer Series:

The December 2026 deadline is 7 months away. Now is the right time to set up your test environment and work through the integration with the reference wallet.


Running eIDAS 2.0 integration on EU-sovereign infrastructure? sota.io is a managed PaaS on Hetzner Germany — no CLOUD Act exposure, no US parent company. Deploy your EUDIW Relying Party backend in minutes.

EU-Native Hosting

Ready to move to EU-sovereign infrastructure?

sota.io is a German-hosted PaaS — no CLOUD Act exposure, no US jurisdiction, full GDPR compliance by design. Deploy your first app in minutes.