Skip to content
Last updated

Same-Device Return

When the holder starts a verification on their phone, in your web app or mobile site, the wallet can send them back to you once they have answered. You give the session a redirect_uri. After the holder presents, declines, or the presentation fails, the wallet shows a button back to you. The button opens your redirect_uri with a fresh response_code.

The response_code also protects the result. A session created with redirect_uri returns its disclosed attributes only to a read that passes the code. The code reaches only the browser the wallet opened. So someone who started the verification and sent the wallet link to somebody else never gets that person's data.

This page builds on the Verification Flow.

When to use it

  • The holder is on their phone, in your web app or mobile site, and you open the session's deeplink there. This is the case the return is made for.
  • The verified data is tied to an account or an action (onboarding, identity proofing, a purchase). Without the return, whoever holds the session id gets the result. That includes someone who started the session themselves and forwarded the deeplink or QR code to another person.

Without redirect_uri, nothing changes: the wallet closes its flow, and the session reads as described in the Verification Flow.

How it works

WalletDeepCredentialsYour backendHolder's browserWalletDeepCredentialsYour backendHolder's browserStart verificationPOST /verification-sessions (redirect_uri)session_id, deeplinkRemember session_id (see "Checking on your side")Open deeplinkWallet opensPresentation (or decline)redirect_uri?response_code=…Holder taps "back to you"redirect_uri?response_code=…GET /verification-sessions/{session_id}?response_code=…Session with attributesContinue
WalletDeepCredentialsYour backendHolder's browserWalletDeepCredentialsYour backendHolder's browserStart verificationPOST /verification-sessions (redirect_uri)session_id, deeplinkRemember session_id (see "Checking on your side")Open deeplinkWallet opensPresentation (or decline)redirect_uri?response_code=…Holder taps "back to you"redirect_uri?response_code=…GET /verification-sessions/{session_id}?response_code=…Session with attributesContinue

Step 1: Create the session with redirect_uri

Both session endpoints take redirect_uri: POST /b2b/v1/verification-sessions and POST /b2b/v1/verification-cases/{caseId}/sessions.

{
  "credential_definition_id": "YOUR_CREDENTIAL_DEFINITION_ID",
  "redirect_uri": "https://app.example.com/verify/return"
}

The URI must be absolute https, with no fragment and no response_code parameter of its own. Anything else is 400 invalid_argument. It may carry its own query parameters. The session echoes it as redirect_uri.

Your return page will need the session_id to read the result. Where you keep it depends on what the result is for. See Checking on your side.

Step 2: The holder comes back

After the holder answers, the wallet offers a button back to you. The button opens:

https://app.example.com/verify/return?response_code=K3x0…

The wallet opens it in the phone's default browser. That is usually the browser the holder started in, but it can be a new tab or, on Android, another browser.

The return happens after a decline or a failed check as well, so your page should expect any outcome.

Step 3: Read the result with the code

On your return page:

  1. Take response_code from the URL, and remove it from the address bar (history.replaceState). It is single-purpose.
  2. Send it to your backend. Your backend finds the session_id (see Checking on your side) and reads:
GET /b2b/v1/verification-sessions/{sessionId}?response_code=K3x0…
ReadAnswer
With the right codeThe full session, attributes included.
Without a codeThe full session, attributes left out and attributes_withheld: true. Status, error_code, credential_status and the rest are there.
With a wrong or expired code403 permission_denied.

The code is valid for 30 minutes after the wallet answered. Read the result when the holder returns, and store what you need. After 30 minutes the session's attributes can no longer be read.

We do not invalidate the code when you read with it, so a retry after a network error works. Use it once yourself: see Use the code once.

Checking on your side

The flow is:

  1. The wallet opens redirect_uri?response_code=C, so the code is in the query of the holder's browser.
  2. Your return page takes C and gives it to your backend.
  3. Your backend calls GET /b2b/v1/verification-sessions/{sessionId}?response_code=C. 200 with attributes means the code belongs to that session, and the person continues. 403 means it does not, and the person does not continue.

The code is created when the wallet answers, and only the wallet gets it: not you, not whoever created the session. So holding the code proves one thing: this browser was opened by the wallet of the person who just verified.

Whether that is enough depends on what you do with the result.

Model 1: the result goes to whoever brings the code

The verification is the start of something new: an application, an order, a sign-up. Nothing exists yet that the result must attach to. Give the result to the browser that brings the code, whichever browser it is.

The return page needs to know which session it is, and the browser it arrives in may not be the one that started. The redirect_uri is fixed when you create the session, before you have its session_id, so put your own reference in it (an order or application id) and map it to the session_id on your side. Your reference may be in the URL: without the code, it reads no data.

on start:
    ref = new application id
    V = POST /b2b/v1/verification-sessions { ..., redirect_uri: ".../verify/return?ref={ref}" }
    store ref → V

on GET /verify/return?ref=R&response_code=C:
    V = lookup R
    if V is none or already used: show "start again"; stop
    session = GET /b2b/v1/verification-sessions/{V}?response_code=C
    if 403: show "this link no longer works, start again"; stop
    mark V used                                     // the code is used once
    continue this browser's flow with session.attributes

Someone who created the session and forwarded the QR code or deeplink to somebody else gets nothing: the code went to the other person's browser.

Model 2: the result attaches to something that existed before

The person is signed in to your service, and the verification confirms the identity of that account. Here the code alone is not enough. The attack:

  1. An attacker signs in to their own account and starts a verification.
  2. They forward the deeplink or QR code to a victim, who presents their credential.
  3. The wallet opens the victim's browser with a valid code.
  4. If you accept the code in any browser and attach the result to the account that started the session, the victim's identity ends up on the attacker's account.

The code is genuine: it came from the person who verified, just not to the place it should go. So check that the code arrives in the same signed-in session that started the verification:

on start (signed-in session S):
    V = POST /b2b/v1/verification-sessions { ..., redirect_uri: ".../verify/return" }
    S.pending_verification = V                      // server-side, not in the URL

on GET /verify/return?response_code=C (session S of this request):
    V = S.pending_verification
    if V is none: show "start the verification again in this browser"; stop
    session = GET /b2b/v1/verification-sessions/{V}?response_code=C
    if 403: refuse; stop
    S.pending_verification = none                   // the code is used once
    attach session.attributes to S's account

In the attack, the victim's browser has no session of the attacker, so nothing is attached. The attacker's session has V but no code, and a read without it returns no attributes.

The price: the wallet opens the phone's default browser. If the person started in another browser, they have no session there and must start again in that browser.

Use the code once

We do not invalidate the code when you read with it, so a retry after a network error still works. Invalidate it yourself. Once a code has opened a result, do not accept it again: the URL that carried it can end up in browser history, proxy logs or error reports.

Why the code is not in the API response

The code reaches you only through your redirect_uri, never in a GET response or a webhook, and the session's attributes come only with it. Had we returned the code to your backend, you would receive the data in any case, and comparing codes would be up to you. An integration that forgot to compare would work exactly the same and have no protection. With the code as the key to the data, there is no way to get the result without the code from the browser the wallet opened. This is the OpenID4VP model (§13.3): the verifier's frontend passes the response code to fetch the wallet's answer.

What else changes for a session with redirect_uri

  • The list never returns its values. GET /b2b/v1/verification-sessions leaves out attributes for such sessions and sets attributes_withheld: true. Read them one session at a time, with the code.
  • The webhook is unchanged. verification.completed carries the session id, status and credential_status, never disclosed data. Use it to learn that the holder answered, then read the result with the code once they are back.
  • Sessions without redirect_uri are unchanged, including those created before this existed.

When the holder scanned a QR code

If the holder scanned a QR code shown on a computer, the wallet's button opens your redirect_uri on the phone, not on the computer. The code reaches the phone's browser. The computer's browser session holds the session_id but never the code, so it cannot read the attributes.

Plan for that. Either have the holder continue on the phone, or create sessions for the QR code on a computer without redirect_uri. Without redirect_uri the session reads as before, but it is not protected against a forwarded QR code. That limit is OpenID4VP's own: binding the result to a browser needs the wallet and the browser on one device.

Error handling

CaseAnswer
redirect_uri not absolute https, with a fragment, or with a response_code400 invalid_argument at creation
Read with a wrong or expired response_code403 permission_denied
Read without response_code200, attributes left out, attributes_withheld: true

See Error Handling for the error format.