Presentation Exchange (OpenID4VP)

Request, present, and verify credentials using OpenID for Verifiable Presentations and Presentation Exchange 2.0.

Last reviewed September 17, 2026Fresh

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.

StepActorActionResult
1Verifiercreate_requestAn authorization request with a nonce and state
2Holder walletout of bandA vp_token delivered to your response_uri
3VerifierverifyPer-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_definition must be an object.
  • id must be a non-empty string.
  • input_descriptors must be an array with at least one entry.
  • Each descriptor needs a non-empty string id, unique within the definition, and a constraints object.
  • If constraints.fields is present it must be an array, and every field needs a non-empty path array.

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.

PatternExampleResolves to
Top-level claim$.issThe JWT issuer
Nested property$.vc.credentialSubject.nationalityThe claim value
Array$.vc.typeThe 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.

OperatorApplies toBehaviour
typeany"array" checks for an array; otherwise compares the JavaScript type
constanyStrict equality
enumanyValue must appear in the list
patternstring or arrayRegex test on a string, or on any element of an array
containsarraySome element must satisfy the nested filter
minimum and maximumnumberInclusive 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>"
  }'
FieldTypeDefaultMeaning
vp_tokenstring or arrayrequiredA VP JWT, a standalone JWT-VC or SD-JWT-VC. An array verifies up to 20 in one call
presentation_definitionobjectabsentWhen present, descriptors are matched and a submission is returned
expected_noncestringabsentCompared with a VP JWT's nonce claim
expected_audiencestringabsentCompared with a VP JWT's aud claim
expected_credential_typesarrayabsentEach credential must carry at least one of these types
bind_to_signal_hashstringabsentWrites a cross-reference on success
skip_issuer_resolutionbooleanfalseSkips 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:

  1. Signature. The ES256 or ES384 JWS against the key in the issuer's DID document, or the legacy PQC-HYBRID envelope's two layers against server-held keys.
  2. Expiry and not-before against the current time.
  3. Issuer DID resolution.
  4. SD-JWT disclosure digests, and the KB-JWT when the credential carries cnf.jwk.
  5. expected_nonce and expected_audience against a VP JWT's claims.
  6. expected_credential_types.
  7. Presentation Exchange matching.
  8. Cross-reference binding, when bind_to_signal_hash is 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

StatusBodyCause
400Missing vp_token—provide a VP JWT string or array of VP JWT stringsNo usable token in the request
400Batch limit exceeded: max 20 vp_tokens per requestMore than 20 entries in the array
400Invalid presentation_definition plus detailsThe definition failed validation
400Missing x-pb-tenant-id headerAPI key sent without the tenant header
400Invalid JSON bodyBody did not parse
401Invalid API keyNo active key with that hash for the tenant
401Invalid auth tokenSession JWT rejected
401Missing authorization or x-pb-api-key headerNo credential presented
403API key missing 'vc_verify' scopeThe key lacks the scope
404No tenant foundSession JWT with no tenant membership
413Payload too largeBody over the medium JSON limit
500Internal server errorUnhandled 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.

  1. Descriptors are processed in order.
  2. For each descriptor, the credentials are tried in order and the first one satisfying every required field wins.
  3. One credential may satisfy several descriptors.
  4. 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