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
ageis 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
vctclaim; the second requiresageto be a number≥ 18.
Prefer DCQL? You can send a
dcqlQueryinstead ofpresentationDefinition— provide exactly one of the two; if you send neither, the request is rejected withEither dcqlQuery or presentationDefinition must be provided.Note one limitation: DCQL claim queries can request disclosure ofageand match exactvalues, but they cannot express a numeric range likeage >= 18— with DCQL your app has to check the disclosedageitself after verification. That is why this tutorial uses the Presentation Exchange definition, which enforces theminimum: 18filter 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, ordc_api.jwt. Defaults todirect_post.jwt.version (optional) —
v1,v1.draft21, orv1.draft24. Defaults tov1— but the default only works withdcqlQuery: a request combiningversion: "v1"with apresentationDefinitionis rejected, so we passv1.draft24here.signingDid (optional) — the DID that signs the authorization request, so wallets can confirm who is asking. If your
:verifierIdpath parameter is itself a DID — as in this tutorial, where we registered the verifier under itsdid:key— you can omitsigningDidand that DID is used. If the verifierId is a plain identifier (like"verifier-empe"),signingDidis required; without it the request is rejected with "Missing signing DID. Provide asigningDidin 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
errorMessageexplains 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