Back

How an OpenID4VP verifier works

The request, the vp_token that comes back, and the seven things a verifier has to validate before it believes any of it. With a real DCQL query.

The exchange in four moves

A verifier builds an authorization request describing the credential it wants. The wallet is invoked with it, on the same device through a URL or across devices through a QR code. The holder consents, and the wallet posts back a vp_token. The verifier then validates what it received, which is the move that takes the longest to get right and the one most guides skip.

One thing worth knowing before reading older material: the query language changed. OpenID4VP 1.0 was finalised in July 2025, and the mechanism for describing what you want is now DCQL, the Digital Credentials Query Language, carried in a dcql_query parameter. It arrived in draft 22 in late 2024 and replaced Presentation Exchange and its presentation_definition. The older parameter is still accepted for compatibility, so plenty of live code and most tutorials predate the change.

The request

Exactly one of dcql_query, presentation_definition, presentation_definition_uri or a scope standing in for one has to be present. Everything else in a minimal request is small: a nonce that binds the presentation to this transaction, a client_id that identifies you, and a response_mode that says how the answer comes back.

response_mode is the parameter that decides the shape of your integration. fragment is the default and returns through the browser. direct_post has the wallet POST the response straight to a URL you control, which is what makes the cross-device flow work and what you want when the response is too large for a redirect. direct_post.jwt is the same thing encrypted.

GET /authorize
  ?response_type=vp_token
  &client_id=x509_san_dns:verifier.example
  &nonce=n-0S6_WzA2Mj
  &response_mode=direct_post
  &response_uri=https://verifier.example/cb
  &dcql_query=<url-encoded JSON below>

The query

DCQL says which credential, in which format, and which claims. Asking for a personhood credential and one claim out of it looks like this. Note that nothing else in the credential is requested, which is the whole point of pairing this with selective disclosure: what is not asked for is not disclosed.

The format string matters and it has moved. SD-JWT VC is dc+sd-jwt, renamed from vc+sd-jwt, and mdoc is mso_mdoc. For SD-JWT VC the meta block carries vct_values, an array the wallet matches the credential type against. For mdoc it carries doctype_value, a single string, and paths are always two segments, namespace then element.

{
  "credentials": [
    {
      "id": "personhood",
      "format": "dc+sd-jwt",
      "meta": {
        "vct_values": ["https://soverage.com/vct/personhood"]
      },
      "claims": [
        { "path": ["personhood_score"] }
      ]
    }
  ]
}

What a verifier has to check

The wallet returns a vp_token. For an SD-JWT VC that is an issuer-signed JWT, the disclosures the holder chose to release, and a key binding JWT signed by the holder. Three signatures worth of work, and each one answers a different question.

The issuer signature says the credential was issued by who it claims. The disclosure digests say the released values are the ones that were signed, rather than values substituted afterwards. The key binding JWT says the party presenting it holds the private key the credential was bound to, and it has to cover both your nonce and your client_id as audience, or a presentation captured from another verifier can be replayed at you.

That last one is where implementations fail, because everything looks correct without it. The credential verifies, the claims parse, and the check you skipped is the one that distinguishes a holder from someone who obtained a valid presentation. After those, the ordinary work: the credential type is the one you asked for, it is inside its validity window, and its status is not revoked.

  1. 01The issuer signature over the issuer-signed JWT verifies.
  2. 02The issuer is one you trust. Not in the protocol, see below.
  3. 03The disclosure digests match the digests that were signed.
  4. 04The key binding JWT signature verifies.
  5. 05The key binding covers your nonce and your client_id as audience.
  6. 06The vct is the type you asked for, and the credential is within its validity window.
  7. 07The status is not revoked.

The part the spec leaves to you

Check two, that the issuer is one you trust, is not in the protocol. OpenID4VP moves credentials and says nothing about who is allowed to issue them, which is deliberate and is also the reason the same protocol serves a state-issued wallet and an open personhood credential without modification.

In the wallet ecosystem that check is a lookup against a trusted list published by a member state, and it is what gives a qualified attestation its legal weight. In an open system it is DID resolution against whatever you have decided to trust. The consequences of the difference are in a separate post, and the practical advice is the same either way: keep that decision in its own layer, because it is the part that changes per credential and per jurisdiction while the rest of this stays the same.

Get your personhood credential

Verify through the Gateway and receive a DID, a verifiable credential and a personhood token. No personal data is retained.

Stay updated.