Presentation Exchange (OpenID4VP)
Request, present, and verify credentials using OpenID for Verifiable Presentations and Presentation Exchange 2.0.
Overview
A verifier asks a wallet for credentials with an OpenID4VP authorization request, and the wallet answers with a vp_token. PasskeyBridge implements both halves of the verifier side in one function, POST https://api.passkeybridge.io/v1/shield-vc-present, with an action field selecting which.
| Step | Actor | Action | Result |
|---|---|---|---|
| 1 | Verifier | create_request | An authorization request with a nonce and state |
| 2 | Holder wallet | out of band | A vp_token delivered to your response_uri |
| 3 | Verifier | verify | Per-credential checks and a presentation submission |
Authenticate with an API key carrying vc_verify plus x-pb-tenant-id, or with a dashboard session JWT: any tenant member, not only an admin. Omitting action runs verify. GET on the same path is a health probe that returns {"ok": true, "service": "shield-vc-present"}.
Verification outcomes are not HTTP failures. A presentation that fails a signature check, a nonce comparison or a descriptor match answers 200 with verified: false and an error string; the status codes in the error table below are for malformed or unauthorized requests.
Writing a presentation definition
A presentation definition names the credentials you will accept. Each input descriptor carries field constraints, and each field carries one or more JSONPath expressions with an optional filter.
{
"id": "identity-verification-v1",
"name": "Identity Verification",
"purpose": "Verify the holder's identity attestation",
"input_descriptors": [
{
"id": "identity-attestation",
"name": "Identity Attestation",
"constraints": {
"fields": [
{
"path": ["$.vc.type"],
"filter": {
"type": "array",
"contains": { "type": "string", "const": "IdentityAttestation" }
}
},
{
"path": ["$.vc.credentialSubject.verificationLevel"],
"filter": { "type": "string", "enum": ["enhanced", "standard"] }
},
{
"path": ["$.vc.credentialSubject.region"],
"optional": true
}
]
}
}
]
}The validator enforces five rules, and reports every violation at once:
presentation_definitionmust be an object.idmust be a non-empty string.input_descriptorsmust be an array with at least one entry.- Each descriptor needs a non-empty string
id, unique within the definition, and aconstraintsobject. - If
constraints.fieldsis present it must be an array, and every field needs a non-emptypatharray.
name and purpose are passed through to the wallet and never affect matching. A descriptor with no fields matches any credential. An invalid definition answers 400:
{
"error": "Invalid presentation_definition",
"details": [
"input_descriptors[0].id is required",
"input_descriptors[1].constraints is required"
]
}JSONPath resolution
The resolver covers the subset Presentation Exchange needs and nothing more. An expression must start with $., and each dot-separated segment is a property name, optionally with a numeric array index.
| Pattern | Example | Resolves to |
|---|---|---|
| Top-level claim | $.iss | The JWT issuer |
| Nested property | $.vc.credentialSubject.nationality | The claim value |
| Array | $.vc.type | The whole type array |
| Array index | $.vc.type[0] | The first entry |
Paths are resolved against the decoded JWT payload, so credential fields live under $.vc, and JWT claims such as $.sub and $.exp are addressable directly. Wildcards, filter expressions and recursive descent are not implemented; an expression using them resolves to nothing.
Within one field, the path array is tried in order and the first expression resolving to a value other than undefined wins, including when that value then fails the filter. A field that resolves nowhere fails the descriptor unless it is marked optional: true.
Supported filter operators
Filters are a small JSON Schema subset evaluated against the resolved value.
| Operator | Applies to | Behaviour |
|---|---|---|
type | any | "array" checks for an array; otherwise compares the JavaScript type |
const | any | Strict equality |
enum | any | Value must appear in the list |
pattern | string or array | Regex test on a string, or on any element of an array |
contains | array | Some element must satisfy the nested filter |
minimum and maximum | number | Inclusive bounds |
The operators are evaluated in that order, and the first one that applies decides the field. type can only reject. const and enum each return their own result immediately, so any pattern, contains, minimum or maximum on the same filter is skipped. pattern decides the field whenever the value is a string or an array. minimum and maximum are reached only when none of the earlier operators applied.
The practical rule: put one deciding operator on a field. To combine a numeric range with an exact value, or a pattern with an enumeration, use two fields with the same path.
Step 1: Create an authorization request
curl -X POST https://api.passkeybridge.io/v1/shield-vc-present \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: <tenant uuid>" \
-H "Content-Type: application/json" \
-d '{
"action": "create_request",
"presentation_definition": { "id": "identity-verification-v1", "input_descriptors": [] },
"response_uri": "https://yourapp.com/oid4vp/callback"
}'response_uri is where the wallet posts its answer. Omitted, it defaults to https://api.passkeybridge.io/shield-vc-present, which is a placeholder rather than a working callback, so set it.
{
"authorization_request": {
"response_type": "vp_token",
"response_mode": "direct_post",
"client_id": "did:web:api.passkeybridge.io:t:<tenant uuid>",
"response_uri": "https://yourapp.com/oid4vp/callback",
"nonce": "a1b2c3d4-...",
"state": "e5f6g7h8-...",
"presentation_definition": { "id": "identity-verification-v1", "input_descriptors": [] },
"client_metadata": {
"client_name": "PasskeyBridge Verifier",
"vp_formats": {
"jwt_vp": { "alg": ["PQC-HYBRID", "ES256", "EdDSA"] },
"jwt_vc": { "alg": ["PQC-HYBRID", "ES256", "EdDSA"] }
}
}
},
"state": "e5f6g7h8-...",
"nonce": "a1b2c3d4-...",
"presentation_definition_id": "identity-verification-v1"
}Both nonce and state are fresh UUIDs. Neither is stored: the platform does not remember the request, so keep the pair in your own session and pass the nonce back as expected_nonce at verification. Deliver authorization_request to the wallet as a deep link, a QR code or a direct call.
The request is written to the audit log as oid4vp.create_request with the descriptor count and the state.
Step 2: Verify the presentation
curl -X POST https://api.passkeybridge.io/v1/shield-vc-present \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: <tenant uuid>" \
-H "Content-Type: application/json" \
-d '{
"action": "verify",
"vp_token": "eyJhbGciOi...",
"presentation_definition": { "id": "identity-verification-v1", "input_descriptors": [] },
"expected_nonce": "a1b2c3d4-...",
"expected_audience": "did:web:api.passkeybridge.io:t:<tenant uuid>"
}'| Field | Type | Default | Meaning |
|---|---|---|---|
vp_token | string or array | required | A VP JWT, a standalone JWT-VC or SD-JWT-VC. An array verifies up to 20 in one call |
presentation_definition | object | absent | When present, descriptors are matched and a submission is returned |
expected_nonce | string | absent | Compared with a VP JWT's nonce claim |
expected_audience | string | absent | Compared with a VP JWT's aud claim |
expected_credential_types | array | absent | Each credential must carry at least one of these types |
bind_to_signal_hash | string | absent | Writes a cross-reference on success |
skip_issuer_resolution | boolean | false | Skips DID resolution. See the caveat below |
A token is treated as a Verifiable Presentation when its payload has a vp claim, and as a standalone credential otherwise. Both are accepted on the same field.
Checks, in the order they run:
- Signature. The ES256 or ES384 JWS against the key in the issuer's DID document, or the legacy
PQC-HYBRIDenvelope's two layers against server-held keys. - Expiry and not-before against the current time.
- Issuer DID resolution.
- SD-JWT disclosure digests, and the KB-JWT when the credential carries
cnf.jwk. expected_nonceandexpected_audienceagainst a VP JWT's claims.expected_credential_types.- Presentation Exchange matching.
- Cross-reference binding, when
bind_to_signal_hashis present.
Two things are worth stating plainly. The detached ML-DSA proof is re-checked here when the endpoint can find it, which means a credential your tenant issued; each credential in the response reports that through post_quantum, and a credential with no stored proof reads checked: false rather than passing quietly. And expected_nonce and expected_audience reach the KB-JWT as well as a VP JWT's own claims, so send the nonce from the authorization request and a replayed presentation is refused.
skip_issuer_resolution: true marks issuer_resolvable as passing and suppresses the DID fetch, which is also where the verifying key for an ES256 or ES384 credential comes from. Those credentials then fail with signature_valid: false. Use it only against legacy PQC-HYBRID credentials.
{
"verified": true,
"holder": "did:web:example.com:users:alice",
"credentials": [
{
"valid": true,
"checks": {
"format_valid": true,
"signature_valid": true,
"not_expired": true,
"not_before_valid": true,
"issuer_resolvable": true,
"sd_disclosures_valid": true,
"kb_jwt_valid": true
},
"issuer": "did:web:passkeybridge.io:tenants:acme",
"subject": "did:web:example.com:users:alice",
"type": ["VerifiableCredential", "IdentityAttestation"]
}
],
"cross_reference_id": null,
"batch": false,
"token_count": 1,
"engine": "native_stage3"
}A failure adds an error string and sets verified to false, keeping the per-credential checks so you can tell an expired credential from a signature failure. presentation_exchange is present when a definition was supplied, and latency_ms carries the duration of the call as the function measured it.
Errors
| Status | Body | Cause |
|---|---|---|
400 | Missing vp_token—provide a VP JWT string or array of VP JWT strings | No usable token in the request |
400 | Batch limit exceeded: max 20 vp_tokens per request | More than 20 entries in the array |
400 | Invalid presentation_definition plus details | The definition failed validation |
400 | Missing x-pb-tenant-id header | API key sent without the tenant header |
400 | Invalid JSON body | Body did not parse |
401 | Invalid API key | No active key with that hash for the tenant |
401 | Invalid auth token | Session JWT rejected |
401 | Missing authorization or x-pb-api-key header | No credential presented |
403 | API key missing 'vc_verify' scope | The key lacks the scope |
404 | No tenant found | Session JWT with no tenant membership |
413 | Payload too large | Body over the medium JSON limit |
500 | Internal server error | Unhandled failure; the response carries nothing else |
Every other outcome is a 200 with verified: false. Read credentials[].checks before assuming the token was malformed: a false signature_valid with everything else true usually means the issuer key could not be fetched, most often because skip_issuer_resolution was set on a standards-based credential.
Matching engine behavior
When a definition is supplied and every token verified, each input descriptor is matched against the decoded credential payloads.
- Descriptors are processed in order.
- For each descriptor, the credentials are tried in order and the first one satisfying every required field wins.
- One credential may satisfy several descriptors.
- A descriptor with no matching credential lands in
unsatisfied_descriptors, and the whole presentation is marked unverified.
{
"presentation_exchange": {
"presentation_submission": {
"definition_id": "identity-verification-v1",
"id": "3c9a...",
"descriptor_map": [
{
"id": "identity-attestation",
"format": "jwt_vp",
"path": "$.vp.verifiableCredential[0]"
}
]
},
"descriptor_matches": [
{
"descriptor_id": "identity-attestation",
"vc_index": 0,
"matched": true,
"field_count": 3,
"fields_satisfied": 3
}
],
"unsatisfied_descriptors": []
}
}field_count counts the fields evaluated and fields_satisfied those whose filter passed, so a descriptor can match with the two numbers differing when an optional field was absent. An unmatched descriptor is reported with vc_index: -1 and matched: false. format in descriptor_map is always jwt_vp and path is always a $.vp.verifiableCredential[...] expression, including when a standalone credential was presented.
When matching fails, the top-level error reads Presentation exchange failed: followed by one line per unsatisfied descriptor.
Cross-reference binding
bind_to_signal_hash links the verified credential subject to a carrier signal you have already ingested. The binding is written only when verification succeeded and at least one credential payload decoded, so a failed presentation can never create one.
{
"action": "verify",
"vp_token": "eyJ...",
"bind_to_signal_hash": "<the signal digest you hold>"
}The value is stored as supplied: pass the platform's own signal digest for that identifier, since a digest computed with a different scheme will never match rows written elsewhere. The VC subject identifier, by contrast, is keyed server-side with HMAC-SHA-256 under the platform pepper before it is stored or compared.
The row in shield_cross_references carries match_method: "native_vc", match_score: 1.0, entity_profile_hash as the SHA-256 of <signal hash>:<subject hash>:native:VerifiableCredential, and hash_version: 1, which reports the weaker of the two schemes in the row. An existing active row for the same pair is returned rather than duplicated, and the lookup matches the current pepper, the previous pepper during a rotation, and the legacy unkeyed digest.
The response returns the row id in cross_reference_id. Confirm it in Dashboard > SIM and credential bindings, where the row shows its match score and both hash prefixes. Bindings created from raw identifiers, passkey binding and revocation are in Deterministic Cross-Reference Binding.
SDK usage
@passkeybridge/vc-wallet-sdk wraps both actions with typed methods.
import { PBWallet } from "@passkeybridge/vc-wallet-sdk";
const wallet = new PBWallet({
apiKey: process.env.PB_API_KEY!,
tenantId: process.env.PB_TENANT_ID!,
});
// Verifier: build the request.
const request = await wallet.createPresentationRequest(
{
id: "identity-verification-v1",
input_descriptors: [
{
id: "identity-attestation",
constraints: {
fields: [
{
path: ["$.vc.type"],
filter: { type: "array", contains: { type: "string", const: "IdentityAttestation" } },
},
],
},
},
],
},
"https://yourapp.com/oid4vp/callback",
);
// request.authorizationRequest goes to the wallet; keep request.nonce.
// Verifier: check what came back.
const result = await wallet.verifyPresentation({
vpToken: holderResponse.vp_token,
presentationDefinition: definition,
expectedNonce: request.nonce,
});
if (result.verified) {
console.log(result.credentials?.[0]?.checks);
}createPresentationRequest returns { authorizationRequest, state, nonce, presentationDefinitionId }. verifyPresentation (aliased as verify) returns { verified, holder, credentials, crossReferenceId, presentationExchange, engine, latencyMs, error }, and throws only on transport and HTTP errors: a presentation that fails verification comes back as verified: false.
verifyBatchPresentation(vpTokens, presentationDefinition?, options?) sends up to 20 tokens in one call and adds batch and tokenCount. Both methods need vc_verify on the key.
The credentialTypeHint option is sent as credential_type_hint, which the native engine ignores; it is read only by the external-provider endpoint. To constrain types here, use expected_credential_types through a presentation definition, or check result.credentials[].type yourself.
Related from the blog
- A Verifier Can Hang Your Wallet with One Regular Expressionengineering · 9 min read
- OpenID4VP over the Digital Credentials API: Browser-Native Wallet Selection in Chrome and Safariengineering · 17 min read
- Credential Chaining: Deriving Trust from a Sequence of Issuer Attestationsengineering · 18 min read