Skip to Content
API and DashboardOpenID4VP Redirect Flow

OpenID4VP redirect flow

Experimental: this may still change before it is stable.

By default, a verification session ends when the wallet submits its authorization response: the wallet stops there, and your application learns the result from a webhook or by polling the API.

The redirect flow instead sends the user’s browser back to your application once the wallet is done, so you can finish the interaction on the page they started on. It also binds the result to the device that started the flow, which is what makes a same-device presentation resistant to session fixation .

You opt in per request by providing a redirectUri together with a client-generated challenge. Both must be provided together. Provide neither to keep the default behaviour.

🔒

A cross-device flow (scanning a QR code with a wallet on another device) that does not use this redirect binding is prone to session fixation and cross-device replay, because nothing ties the device that started the flow to the device that presented the credential. See Assurance level and security model for how to choose the right setup.

How the binding works

Two secrets are involved, and the security comes from neither one being sufficient on its own.

  • The challenge is generated by your client and kept by the browser that started the flow. It never appears in the redirect URI, so intercepting the redirect does not reveal it. Paradym only stores a SHA-256 hash of it.
  • The response_code is generated by Paradym and travels back through the redirect as a response_code query parameter. It is single-use.

After the presentation is verified the result is withheld: the session sits in the pendingRedirect status, and no data is returned. Only when both values are submitted together does Paradym release the result, and only in that response. An attacker who intercepts the redirect gets the response_code but not the challenge, and an attacker who initiated their own flow has a challenge that does not match the session.

The exchange is single-use and has its own 5 minute window that starts when the presentation is verified, not when the request was created. A request with a long expirationInMinutes therefore does not leave the result retrievable for that whole period. If the window passes the session expires and the withheld data is discarded.

Withheld means it is not readable, not that it is absent: Paradym holds the presented data until the exchange or the expiry, because it has to survive until you ask for it. This is the one case where a wallet set to webhook has its data stored, and it is deleted the moment the exchange completes or the window closes.

Worked example

The flow spans your browser and your server, and the split matters: your Paradym API key must never reach the browser, and the challenge belongs to the browser that started the flow. So session creation and the exchange happen server-side, while generating the challenge and pairing it with the response code happen client-side. Your server passes the challenge on to Paradym in both calls, but should not store or log it.

Generate the challenge and start the verification

The challenge must be unpredictable and unique per request. Generate it in the browser and keep it there.

browser
function generateChallenge() { const bytes = crypto.getRandomValues(new Uint8Array(16)) return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') } const challenge = generateChallenge() const response = await fetch('/api/start-verification', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ challenge }), }) const { id, authorizationRequestDeeplinkUri } = await response.json() // Keep the challenge and the session id for when the wallet returns. sessionStorage.setItem('verification', JSON.stringify({ id, challenge })) // Same device: send the user straight to their wallet. window.location.href = authorizationRequestDeeplinkUri

sessionStorage is scoped to a single tab. If the wallet returns the user to a different tab or browser the challenge is gone and the result can no longer be retrieved. That is inherent to binding the result to the session that started it, not a bug: it is the same property that stops an attacker retrieving it.

Create the session on your server

Your server holds the API key and passes the challenge through, along with the URI the wallet should return the user to.

server
// POST /api/start-verification export async function startVerification(req, res) { const { challenge } = req.body const response = await fetch( `https://api.paradym.id/v1/wallets/${WALLET_ID}/openid4vc/verification/request`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-access-token': PARADYM_API_KEY }, body: JSON.stringify({ presentationTemplateId: PRESENTATION_TEMPLATE_ID, redirectUri: 'https://your-app.example.com/verify/callback', challenge, }), } ) const session = await response.json() // Only hand back what the browser needs. Never return the API key or the response code. res.json({ id: session.id, authorizationRequestDeeplinkUri: session.authorizationRequestDeeplinkUri }) }

Read the response code when the wallet returns

The wallet sends the user to https://your-app.example.com/verify/callback?response_code=<code>. Only the browser that started the flow has the challenge, so the two are paired there.

browser, /verify/callback
const responseCode = new URLSearchParams(window.location.search).get('response_code') const stored = sessionStorage.getItem('verification') // Single-use, so keep it out of the history entry and out of any referrer this page sends on. window.history.replaceState(null, '', window.location.pathname) if (!responseCode || !stored) { // Reached the callback without a redirect, or in a different tab than the one that started the flow. showError('This verification could not be completed. Please start again.') } else { const { id, challenge } = JSON.parse(stored) sessionStorage.removeItem('verification') const response = await fetch('/api/complete-verification', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, challenge, responseCode }), }) const result = await response.json() if (!response.ok) showError(result.message) else if (result.error) showError('The presentation could not be verified.') else showCredentials(result.credentials) }

As a query parameter the code also reaches your server, so it appears in request lines and access logs. Completion needs the challenge as well, which is never part of a URL, so a leaked code cannot be redeemed on its own.

Complete the verification to get the result

server
// POST /api/complete-verification export async function completeVerification(req, res) { const { id, challenge, responseCode } = req.body const response = await fetch( `https://api.paradym.id/v1/wallets/${WALLET_ID}/openid4vc/verification/${id}/complete`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-access-token': PARADYM_API_KEY }, body: JSON.stringify({ challenge, responseCode }), } ) res.status(response.status).json(await response.json()) }

On success the session moves to verified and the verification data is returned. For a redirect-flow session, this response releases the data in place of your wallet’s verification data access setting: the openid4vc.verification.data webhook is not sent, and Paradym deletes the data right after, so store what you need from the response.

Handling the outcomes

The wallet redirects the user back whether the presentation succeeded or failed, so landing on your callback does not by itself mean verification worked.

When the user declines the request, some wallets don’t notify the verifier and don’t redirect, so your callback is never reached. The session stays in requestRetrieved until it expires. Give the user a way to restart from the page that started the flow.

ResultWhat it meansWhat to do
200 with error: nullVerified. credentials holds the presented data.Continue the user’s journey.
200 with error setThe presentation itself failed. Returned as a session rather than an error so you can tell it apart from a bad challenge.Show a “verification failed” screen and offer a retry.
400The challenge or response_code did not match.Treat as a failed attempt. Do not retry with the same values.
425The wallet’s redirect arrived before Paradym finished verifying the presentation.Retry with the same values after a short delay.
409The session is not awaiting completion: it was already completed (completion is single-use), the 5 minute window passed, or it was not created for the redirect flow.After a double-submit, show the result you already have. Otherwise ask the user to start again.
404No verification session with that id in this wallet.Ask the user to start again.

Paradym compares both values in constant time and does not reveal which of the two was wrong, so a 400 should be handled as a single “could not be verified” case.

Enforcing the redirect flow

Opting in is per request, so any caller can omit the two fields and fall back to an unbound flow. To make the binding mandatory, set openId4Vc.allowedFlows to ["sameDevice"] on the presentation template. Verification requests created from that template are then rejected with a 400 unless they provide both a challenge and a redirectUri.

This only affects OpenID4VC. DIDComm verification requests using the same template are unaffected.

Where to go next

Last updated on