For the complete documentation index, see llms.txt. This page is also available as Markdown.

Setting Up the Verification Flow

What We Are Doing:

  • Designing a verification flow we'll call "kyc" that asks a wallet to present a KYC credential and checks that:

    • the credential is of the expected type (a KYC credential), and

    • the subject's age is at least 18.

  • Expressing that requirement in a standard query the Verifier understands, and deciding what our app does once the wallet's presentation is verified.

Why: We want only users who hold a valid KYC credential proving they are over 18 to reach a protected resource. The Verifier service does the cryptographic work for us — it builds and signs an OpenID4VP authorization request, hands the wallet the requirements, verifies the returned presentation, and tracks the result in a verification session. Our job is to describe what must be presented and to react to the verified result, not to validate signatures or selective-disclosure ourselves.

A verification flow is simply the request body you send to the Verifier when you create an authorization request: a description of the credential and claims you require. The platform supports two query languages for this — DIF Presentation Exchange v2 (presentationDefinition) and DCQL (dcqlQuery). You provide exactly one of them.

Step 1 — Describe the requirement

Let's express the "kyc" flow as a Presentation Exchange v2 definition: the wallet must present a credential of the KYC type and disclose an age claim whose value is at least 18. This is the body you'll send to POST /verifiers/:verifierId/authorization-requests in the next step.

// src/verification-flows.js
// A reusable description of the "kyc" verification flow.
// Sent as the `presentationDefinition` when creating an authorization request.

export const kycPresentationDefinition = {
  id: "kyc",
  input_descriptors: [
    {
      id: "kyc_credential",
      // Restrict to SD-JWT VC credentials of the expected type (vct).
      format: {
        "vc+sd-jwt": { "sd-jwt_alg_values": ["ES256", "EdDSA"] }
      },
      constraints: {
        // Require the holder to actually disclose the matching claims.
        limit_disclosure: "required",
        fields: [
          {
            // The credential type for SD-JWT VC lives in the `vct` claim.
            path: ["$.vct"],
            filter: {
              type: "string",
              const: "https://your-issuer.evdi.app/vct/kyc-credential"
            }
          },
          {
            // The subject must be at least 18.
            path: ["$.age"],
            filter: { type: "number", minimum: 18 }
          }
        ]
      }
    }
  ]
};
  • id — an identifier for the definition; "kyc" keeps it readable in logs.

  • input_descriptors — one entry per credential you want presented. Here we ask for a single KYC credential.

  • format — which credential format(s) you accept. We accept vc+sd-jwt (SD-JWT VC), the same format the Issuer used earlier in this tutorial.

  • constraints.limit_disclosure: "required" — instructs the wallet to disclose only the listed fields, nothing more.

  • constraints.fields — the actual checks. The first field pins the credential type via the SD-JWT vct claim; the second requires age to be a number ≥ 18.

Prefer DCQL? You can send a dcqlQuery instead of presentationDefinition — provide exactly one of the two; if you send neither, the request is rejected with Either dcqlQuery or presentationDefinition must be provided. Note one limitation: DCQL claim queries can request disclosure of age and match exact values, but they cannot express a numeric range like age >= 18 — with DCQL your app has to check the disclosed age itself after verification. That is why this tutorial uses the Presentation Exchange definition, which enforces the minimum: 18 filter inside the Verifier.

Step 2 — Where the flow plugs in

When you create an authorization request, you pass this definition together with a couple of OpenID4VP options. The Verifier returns an openid4vp:// request URI (to show as a QR code) and a verification session id to track:

  • presentationDefinition — the "kyc" definition from Step 1.

  • responseMode (optional) — direct_post, direct_post.jwt, dc_api, or dc_api.jwt. Defaults to direct_post.jwt.

  • version (optional) — v1, v1.draft21, or v1.draft24. Defaults to v1 — but the default only works with dcqlQuery: a request combining version: "v1" with a presentationDefinition is rejected, so we pass v1.draft24 here.

  • signingDid (optional) — the DID that signs the authorization request, so wallets can confirm who is asking. If your :verifierId path parameter is itself a DID — as in this tutorial, where we registered the verifier under its did:key — you can omit signingDid and that DID is used. If the verifierId is a plain identifier (like "verifier-empe"), signingDid is required; without it the request is rejected with "Missing signing DID. Provide a signingDid in the request body when the verifierId is not a DID."

The body also accepts other OpenID4VP options — authorizationResponseRedirectUri, expectedOrigins (Digital Credentials API), transactionData, and verifierInfo — see the API reference for the full list.

The full HTTP call — with the Authorization: Bearer token and the frontend QR/SSE wiring — is covered on the next page, Creating a Verification Endpoint. Here we only care that the body above is the "kyc" flow.

Step 3 — React to the verified result

You don't poll for "is it done yet". Instead you watch the verification session, either by reading it once with GET /verification-sessions/:sessionId or by subscribing to the live GET /verification-sessions/:sessionId/events SSE stream. Each update carries the session's current state:

  • RequestCreated — the request was created and is waiting to be scanned.

  • RequestUriRetrieved — the wallet fetched the request and is preparing a presentation.

  • ResponseVerified — the presentation passed every check in your definition (type + age ≥ 18). This is the success state.

  • Error — verification failed; the session's errorMessage explains why.

Because the Verifier enforces the constraints before reaching ResponseVerified, your code can treat that state as "this user holds a KYC credential and is at least 18" and mint its own short-lived application token. The verification-session record reports only the session state — it does not include the holder DID or the disclosed claims (GET /verification-sessions/:sessionId and the SSE stream return id, verifierId, state, authorizationRequestId, authorizationRequestUri, authorizationResponseRedirectUri, expiresAt, and errorMessage). If you want the holder DID and the disclosed age/first_name/last_name in your app token (so the dashboard can show them), obtain them yourself — for example by verifying the presented credential string with POST /credentials/verify, which returns { isValid, format, payload, prettyClaims }. For SD-JWT VCs issued through the credential-offer flow, the holder binding lives in payload.cnf — cnf.kid carries the holder's DID verification method (e.g. did:key:z6Mk...#z6Mk...); payload.sub is set only on directly signed credentials where a subjectDid was provided. Sign the values you obtained into your own token:

The session object you receive looks like this:

Notice the session record reports the state of the verification, not the disclosed claims. For our gate that's enough — we only needed to know the presentation satisfied the "kyc" requirements. The holder's DID will be a did:key or did:web — the methods wallets in this ecosystem use.

Now our app has everything it needs: a precise description of the "kyc" flow to send to the Verifier, and a clear rule for what to do once a session reaches ResponseVerified. Next, we'll wire this into a real endpoint that requests a QR code and watches the session in real time.

Last updated