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.
- The holder is on their phone, in your web app or mobile site, and you open the session's
deeplinkthere. 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.
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.
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.
On your return page:
- Take
response_codefrom the URL, and remove it from the address bar (history.replaceState). It is single-purpose. - 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…| Read | Answer |
|---|---|
| With the right code | The full session, attributes included. |
| Without a code | The full session, attributes left out and attributes_withheld: true. Status, error_code, credential_status and the rest are there. |
| With a wrong or expired code | 403 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.
The flow is:
- The wallet opens
redirect_uri?response_code=C, so the code is in the query of the holder's browser. - Your return page takes
Cand gives it to your backend. - Your backend calls
GET /b2b/v1/verification-sessions/{sessionId}?response_code=C.200withattributesmeans the code belongs to that session, and the person continues.403means 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.
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.attributesSomeone 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.
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:
- An attacker signs in to their own account and starts a verification.
- They forward the deeplink or QR code to a victim, who presents their credential.
- The wallet opens the victim's browser with a valid code.
- 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 accountIn 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.
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.
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.
- The list never returns its values.
GET /b2b/v1/verification-sessionsleaves outattributesfor such sessions and setsattributes_withheld: true. Read them one session at a time, with the code. - The webhook is unchanged.
verification.completedcarries the session id, status andcredential_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_uriare unchanged, including those created before this existed.
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.
| Case | Answer |
|---|---|
redirect_uri not absolute https, with a fragment, or with a response_code | 400 invalid_argument at creation |
Read with a wrong or expired response_code | 403 permission_denied |
Read without response_code | 200, attributes left out, attributes_withheld: true |
See Error Handling for the error format.