@openeudi/openid4vp
v0.16.1
Published
OpenID4VP credential parsing and validation for EUDI Wallets (SD-JWT VC + mDOC)
Maintainers
Readme
@openeudi/openid4vp
OpenID4VP credential parsing and validation for EUDI Wallets. Supports SD-JWT VC and mDOC credential formats with issuer trust verification, expiry checking, selective disclosure claim extraction, and DCQL-based credential matching.
Install
npm install @openeudi/openid4vpSecurity and maturity
This library verifies credentials for identity and age-assurance flows, so its posture is stated here rather than left to be inferred from the feature list.
No independent third-party security audit has been carried out. The code has not been reviewed by an external security firm.
Two high-severity vulnerabilities have been found and fixed, both reported by outside researchers:
| Advisory | Issue | Fixed in | | -------- | ----- | -------- | | GHSA-4c2f-96cf-f5fc (CVE-2026-75522) | X.509 chain building terminated on Subject DN string equality rather than cryptographic closure, so a forged certificate reusing a trusted anchor's DN was accepted without signature verification | 0.8.1 | | GHSA-h548-cr7v-4v97 (CVE-2026-77456) | Holder binding was not enforced: an SD-JWT VC's KB-JWT was optional, and mDOC device authentication was never verified, so a captured presentation could be replayed | 0.9.0 |
Both are fixed. Both were found externally rather than by this project's own testing — weigh that accordingly when deciding how much to rely on the library, and always run the latest release.
Pre-1.0. While the version is below 1.0, minor bumps may contain breaking changes; see the migration list. Full EUDI ARF (v3.0.0 at the time of writing) profile compliance is a 1.0 goal, not a current claim.
What is independently exercised. The OpenID Foundation conformance suite runs against this library in CI on every pull request, covering the 1.0-Final verifier happy flow for both sd_jwt_vc and iso_mdl plus the holder-binding negatives. Separately, a downstream deployment built on this library — eudi-verify 1.4.0 — is OpenID Certified for the OID4VP-1.0+HAIP-1.0 Verifier iso_mdl direct_post.jwt profile (14 Aug 2026). That certification is the deployment's, not the library's, but it exercised this code.
Known functional gaps that bear on what you can rely on are listed under Scope and limitations. To report a vulnerability, use private reporting as described in SECURITY.md — please do not open a public issue.
Quick start
Parse a Verifiable Presentation token and extract identity claims:
import { parsePresentation, StaticTrustStore } from "@openeudi/openid4vp";
const result = await parsePresentation(vpToken, {
// DER certificates of the PID providers you accept (e.g. from the PID-providers list)
trustStore: new StaticTrustStore(listedCertificates),
nonce: "expected-nonce-value",
// Your client_id as sent in the request: the KB-JWT `aud` must equal it
audience: "x509_san_dns:verifier.example.com",
});
if (result.valid) {
console.log(result.format); // 'sd-jwt-vc' | 'mdoc'
console.log(result.vct); // 'urn:eudi:pid:1', or a national type such as 'urn:eudi:pid:de:1'
console.log(result.claims.birthdate); // '1990-01-01' — a PID has no age_over_18; derive the age from this
} else {
console.error(result.error);
}parsePresentation automatically detects the credential format. String tokens with ~ separators are parsed as SD-JWT VC; binary Uint8Array tokens are parsed as CBOR-encoded mDOC. It verifies one credential and does not know your DCQL query, so it cannot apply the query's holder-binding requirement — use verifyAuthorizationResponse (or verifyPresentation) in a real verifier.
Authorization requests
Build an OpenID4VP authorization request URI to send to an EUDI Wallet. The request carries a DCQL query (Digital Credentials Query Language) describing the credentials you want:
import { buildHaipQuery, createAuthorizationRequest } from "@openeudi/openid4vp";
const query = buildHaipQuery({
credentialId: "pid",
format: "dc+sd-jwt",
vctValues: ["urn:eudi:pid:1"],
claims: ["birthdate"],
});
const request = createAuthorizationRequest(
{
clientId: "x509_san_dns:verifier.example.com",
responseUri: "https://verifier.example.com/cb",
nonce: crypto.randomUUID(),
},
query,
);
console.log(request.uri);
// openid4vp://authorize?response_type=vp_token&response_mode=direct_post&...
console.log(request.state);
// auto-generated UUID unless you provide one
console.log(request.dcqlQuery);
// the DCQL query embedded in the requestAuthorizationRequestInput
| Field | Type | Required | Description |
| -------------- | -------- | -------- | ---------------------------------------------- |
| clientId | string | Yes | Your verifier client identifier |
| responseUri | string | Yes | Callback URL for the wallet response |
| nonce | string | Yes | Challenge nonce for replay protection |
| state | string | No | Session state (auto-generated UUID if omitted) |
The second argument is a DCQL Query object. Use buildHaipQuery (below) or hand-construct one and validate it via validateHaipQuery. createAuthorizationRequest, createSignedAuthorizationRequest and createDcApiRequest run @openeudi/dcql validateQuery on it first and throw its DcqlValidationError (code, path) for a query a wallet must reject (OpenID4VP 1.0 §6–§7): meta missing (vct_values for dc+sd-jwt, doctype_value for mso_mdoc, {} otherwise), an id outside [A-Za-z0-9_-], an mso_mdoc claim path that is not exactly [namespace, element], empty claims / values / trusted_authorities, and so on.
HAIP helpers
For the High Assurance Interoperability Profile (HAIP) commonly used by EUDI Wallets:
import { buildHaipQuery, validateHaipQuery } from "@openeudi/openid4vp";
// Build a HAIP-compliant DCQL query:
const query = buildHaipQuery({
credentialId: "pid",
format: "dc+sd-jwt",
vctValues: ["urn:eudi:pid:1"],
claims: ["birthdate", "given_name"],
});
// Or validate a hand-built DCQL query:
validateHaipQuery(query); // throws HaipValidationError on violationThe HAIP rules run first, with their own codes; then every @openeudi/dcql validateQuery rule, reported as HaipValidationError INVALID_DCQL (the message names the JSON pointer). A credential id outside [A-Za-z0-9_-] is INVALID_CREDENTIAL_ID. buildHaipQuery and buildCredentialSetQuery apply the same checks to what they build.
Supported formats: dc+sd-jwt and mso_mdoc. Other formats (e.g., jwt_vc_json) will be rejected by the validator.
Known EUDI doctypes auto-namespace their claim paths (e.g., org.iso.18013.5.1.mDL → claims under org.iso.18013.5.1). Unknown doctypes use the full doctype string as the namespace.
Offering the wallet a choice (credential_sets)
Some questions can be answered by more than one credential. The clearest case is
proof of age: a wallet may hold a Proof-of-Age attestation (mso_mdoc, with
age_over_18 in namespace eu.europa.ec.av.1) or only a PID (dc+sd-jwt,
carrying birthdate — the PID has no age attribute, so the verifier computes the
threshold; birth_date is the mdoc element name of the PID). Asking for both in one request avoids a second wallet interaction:
import { buildCredentialSetQuery } from "@openeudi/openid4vp";
const query = buildCredentialSetQuery({
options: [
// Most-preferred first — discloses only a boolean.
{
credentialId: "age-attestation",
format: "mso_mdoc",
doctypeValue: "eu.europa.ec.av.1",
claims: ["age_over_18"],
},
// Fallback — the verifier computes the threshold from birthdate.
{
credentialId: "pid",
format: "dc+sd-jwt",
vctValues: ["urn:eudi:pid:1"],
claims: ["birthdate"],
},
],
});The wallet satisfies one option and returns one presentation;
verifyAuthorizationResponse resolves it by the id the wallet keyed vp_token
with. Option order is a privacy preference signal, not a constraint — wallets are
not obliged to honour it.
Validate a hand-built disjunction with validateCredentialSetQuery. Note this is
deliberately not HAIP-minimal: validateHaipQuery rejects credential_sets
and isHaipQuery returns false for these queries.
match.unmatchedis non-empty on success here. A disjunction is satisfied when the wallet takes one option, so the option it did not take is reported as unmatched — typically withformat_mismatchorvct_mismatchagainst the credential the wallet never held. That is the expected result, not a failure:// Wallet presented the PID; the attestation option was not taken. result.valid // true result.match.satisfied // true result.match.matches // [{ queryId: "pid", ... }] result.match.unmatched // [{ queryId: "age-attestation", reason: "format_mismatch" }]Gate on
result.valid(ormatch.satisfied), never onmatch.unmatched.length === 0. The latter is the correct test only for a query withoutcredential_sets, and silently rejects every valid disjunction response. If you logunmatchedas "mismatch reasons" (as the verification example below does), expect a benign entry per successful disjunction and filter accordingly so it does not trip alerting.
Not ZKP. This is the plain
mso_mdocattestation path. The AV technical specification's preferred mechanism — a Zero-Knowledge Proof — is not supported over OpenID4VP (no standardised DCQL query for it exists); it rides only the W3C Digital Credentials API. Plain mDoc is the profile's designated fallback for this transport.
Verifying presentations against a query
Use verifyPresentation to combine crypto/structural verification with DCQL matching in a single call:
import { verifyPresentation } from "@openeudi/openid4vp";
const result = await verifyPresentation(vpToken, query, {
nonce,
audience: verifierClientId, // expected KB-JWT `aud`
trustStore,
});
if (result.valid) {
console.log("matched claims:", result.match.matches[0].extractedClaims);
console.log("submission:", result.submission);
} else {
console.warn("mismatch reasons:", result.match.unmatched);
// each entry: { queryId, reason, detail? }
// reason ∈ { format_mismatch, vct_mismatch, doctype_mismatch, holder_binding_required, missing_claims, value_mismatch, trusted_authority_mismatch, no_credential_found /* only when the candidate list is empty */ }
}Mismatches return valid: false — they do not throw. Only crypto/structural failures (malformed VP tokens, invalid signatures, expired credentials), configuration faults (MissingAudienceError) and malformed DCQL queries throw exceptions.
Holder binding follows the query. OpenID4VP 1.0 §6.1 makes require_cryptographic_holder_binding default to true, so a bearer SD-JWT VC (no cnf, no KB-JWT) yields valid: false unless the matched credential query sets require_cryptographic_holder_binding: false. mDOC device authentication is always required. The presentation's binding is also passed to DCQL (holder_bound), so any other query entry that requires binding reports holder_binding_required for a bearer credential.
National PID types. A wallet may answer vct_values: ["urn:eudi:pid:1"] with a national PID such as urn:eudi:pid:de:1 (PID Rulebook PID_14, OpenID4VP 1.0 Appendix B.3.5). Such a credential matches when its issuer-signed aka_vcts lists a requested type, or when you declare the inheritance out of band:
await verifyPresentation(vpToken, query, {
nonce,
audience: verifierClientId,
trustStore,
vctAncestors: { "urn:eudi:pid:de:1": ["urn:eudi:pid:1"] }, // walked transitively
});
// result.parsed.vct is still the credential's own type, 'urn:eudi:pid:de:1'Type Metadata is never fetched to resolve extends.
trusted_authorities. An aki value is the base64url (unpadded) KeyIdentifier of an AuthorityKeyIdentifier in the credential's certificate chain (OpenID4VP 1.0 §6.1.1.1); the library offers the AKI of every certificate in the validated chain plus the anchor's SKI. An etsi_tl value is a trusted-list URL — LotlTrustStore anchors offer their national TL and the LOTL. Each value only satisfies an entry of its own type. The hex SKI form used before 0.14.0 still matches aki for one release, with a one-time deprecation warning.
The else branch above is the right place to read unmatched for an ordinary
query. For a credential_sets disjunction it is not the whole story: the
option the wallet did not take is reported as unmatched even when valid is
true. See Offering the wallet a choice.
Privacy — diagnostics are verifier-internal.
match.unmatched[].reasonanddetail(includingvalue_mismatch) are intended for verifier-side logging, debugging, and admin UIs. OpenID4VP §11 warns that per-claim verification outcomes can reveal wallet contents to observers. Do NOT echo these diagnostics into the OpenID4VP wire response sent back to the wallet, into end-user-visible error messages that another party could correlate, or into public analytics/third-party logs. The protocol's own error codes are the public interface; these fields are your internal instrumentation.
Signed authorization requests (x509_san_dns, x509_hash)
For flows that require a signed request object (JAR) per OpenID4VP 1.0 §5.10, use createSignedAuthorizationRequest:
import { createSignedAuthorizationRequest } from "@openeudi/openid4vp";
const req = await createSignedAuthorizationRequest({
clientIdPrefix: "x509_san_dns", // or "x509_hash"; defaults to "x509_san_dns"
hostname: "verifier.example.com", // required for x509_san_dns; optional for x509_hash
requestUri: "https://verifier.example.com/request.jwt",
responseUri: "https://verifier.example.com/response",
nonce,
signer: verifierKeyPair, // CryptoKeyPair with public+private
certificateChain: [leafCertDer], // DER; leaf (+ intermediates), never the trust anchor; leaf SAN DNSName must include hostname when one is given
encryptionKey: {
publicJwk: encryptionPublicJwk, // a fresh key pair per request; must include alg, e.g. "ECDH-ES"
},
vpFormatsSupported: {
"dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"] },
},
}, dcqlQuery);
// req.uri — the short URI to hand to the wallet
// req.requestObject — the JWS the verifier must host at requestUri
// (Content-Type: application/oauth-authz-req+jwt)clientIdPrefix: "x509_hash" sets client_id to x509_hash: followed by the base64url-encoded SHA-256 hash of the DER-encoded leaf certificate, per OpenID4VP 1.0 §5.9.3 (referenced by HAIP 1.0 Final for its mandated Client Identifier Prefix). hostname is not required in this mode, since the client_id is derived from the certificate rather than the host. If you do supply one it is still checked against the leaf SAN DNSName values — a supplied hostname that the certificate does not cover is treated as a misconfiguration (hostname_cert_mismatch) rather than silently ignored.
The caller hosts req.requestObject at requestUri (the library does not host HTTP). The library verifies that the signing key's public SPKI matches the leaf certificate's public key — an attempt to sign with a mismatched key fails with SignedRequestBuildError: signing_key_cert_mismatch.
Self-signed verifier certificates are rejected
HAIP 1.0 Final requires the verifier certificate to chain to a trust anchor the wallet recognises. A self-signed leaf asserts an identity nothing vouches for, so a wallet enforcing the profile will reject the request object. createSignedAuthorizationRequest therefore rejects a self-signed leaf by default with SignedRequestBuildError: self_signed_leaf, failing at build time rather than emitting a request no conforming wallet will honour.
// Local development or tests only — not valid under HAIP 1.0 Final.
await createSignedAuthorizationRequest({
...input,
allowSelfSignedCertificate: true,
}, dcqlQuery);The check verifies the leaf's signature against its own public key rather than comparing Subject and Issuer DN strings. That distinction is deliberate: GHSA-4c2f-96cf-f5fc was a DN-string-equality bug, and a DN comparison here would both miss a re-signed certificate and wrongly reject a legitimate cross-signed one.
The emitted client_metadata carries the 1.0 Final shape: encrypted_response_enc_values_supported: ["A128GCM", ...].
Request keys and certificate chain (HAIP 1.0 §5)
- Ephemeral encryption key. "Verifiers MUST supply ephemeral encryption public keys specific to each Authorization Request." Generate a new
encryptionKeyfor every request and keep its private key only to decrypt that request's response. The library cannot tell whether a key was reused, so this is on you. kid. OpenID4VP 1.0 §5.1 requires akidon every JWK inclient_metadata.jwks. ApublicJwkwithout one is given its RFC 7638 SHA-256 thumbprint askid; akidyou set is kept.- No trust anchor in
x5c. "The X.509 certificate of the trust anchor MUST NOT be included in the x5c JOSE header." AcertificateChainwith more than one certificate whose last certificate is self-signed is rejected withSignedRequestBuildError: trust_anchor_in_chain. Pass the leaf and any intermediates only.
Verifier info and registration certificates (verifier_info)
verifierInfo adds the OpenID4VP 1.0 §5.1 verifier_info parameter: attestations about the verifier, each { format, data, credential_ids? }. ETSI TS 119 472-2 V1.3.1 (§6.3.2.2) makes it mandatory in an EUDI Wallet request object. It must hold a registrar_dataset element with the data the relying party registered with its national Registrar, plus a registration_cert element when the relying party holds a registration certificate (WRPRC, ETSI TS 119 475). Two helpers build these elements:
import {
createSignedAuthorizationRequest,
registrarDatasetVerifierInfo,
registrationCertVerifierInfo,
} from "@openeudi/openid4vp";
const verifierInfo = [
registrarDatasetVerifierInfo({
identifier: [{ type: "http://data.europa.eu/eudi/id/LEI", identifier: "LEIXG-529900T8BM49AURSDO55" }],
srvDescription: [{ lang: "en", content: "Age check at checkout" }],
registryURI: "https://registrar.example/api",
intendedUseIdentifier: registeredIntendedUseId,
purpose: [{ lang: "en", content: "Checking the legal minimum age" }],
policyURI: "https://shop.example/privacy",
// optional: the attestations and claims registered for this intended use
credential: [{ format: "dc+sd-jwt", meta: { vct_values: ["urn:eudi:pid:1"] }, claim: [{ path: ["birthdate"] }] }],
}),
// only when the relying party holds a WRPRC: compact JWS (typ rc-wrp+jwt) or CWT bytes
registrationCertVerifierInfo(wrprcJws),
];
const req = await createSignedAuthorizationRequest({ ...input, verifierInfo }, dcqlQuery);Rules the builder enforces before signing (SignedRequestBuildError: invalid_verifier_info):
verifierInfois a non-empty array, eachformata non-empty string, and eachdataa non-empty string or an object.credential_ids, when present, is non-empty and each entry matches theidof a Credential Query in the DCQL query.registrar_datasetandregistration_certnever carrycredential_ids(TS 119 472-2 RO-03, RO-14), and each appears at most once. ARF RPRC_19 allows "a single registration certificate" per request.
Other formats are passed through unchanged. The signed and unsigned builders emit only format, data and credential_ids.
Notes:
- Encoding of
registration_cert. RO-16 requiresdatato be "the base64url encoding of the serialized RP registration certificate". For a JWT WRPRC,registrationCertVerifierInfotherefore base64url-encodes the compact JWS string itself, so the JWS is encoded twice. - Intermediaries. When you request on behalf of another relying party,
identifierand the WRPRC are the final relying party's, not yours (TS 119 472-2 RO-12 NOTE 2, TS 119 475 §4.5), while the request is signed with the intermediary's access certificate. - Data model inconsistencies. TS 119 472-2 types
Credential.metaandClaim.pathas text, while the TS 119 475 Annex C example uses DCQL-style objects and arrays. The helper accepts both and passes them through unchanged. createAuthorizationRequest(unsigned) acceptsverifierInfotoo and JSON-encodes it into the URI. Nothing binds it to the verifier, though, so profiles that rely on it expect a signed request object.
Authorization responses (direct_post and direct_post.jwt)
Wallets POST the Authorization Response to your responseUri. The library is stateless — you MUST compare the envelope's state against the value you issued before treating the response as trustworthy. The recommended pattern differs slightly between the unencrypted and encrypted modes.
Unencrypted (direct_post)
The envelope arrives as form-encoded JSON; parse it, check state, then verify.
mDOC SessionTranscript: pass clientId and responseUri (exactly as sent in the request) and verifyAuthorizationResponse auto-builds the OpenID4VP 1.0 Final OpenID4VPHandover transcript with a null JWK thumbprint, as §B.2.6.1 requires for unencrypted responses:
import { verifyAuthorizationResponse } from "@openeudi/openid4vp";
const envelope = parsedVpTokenObject; // { vp_token, state, ... }
if (envelope.state !== submittedState) {
throw new Error("state mismatch — possible CSRF / replay");
}
const result = await verifyAuthorizationResponse(envelope, dcqlQuery, {
trustedCertificates: [issuerCertDer],
nonce,
clientId: verifierClientId,
responseUri: verifierResponseUri,
// auto-builds OpenID4VPHandover [client_id, nonce, null, response_uri]
});Draft-era (Annex B) wallets have no unencrypted transcript the library can derive (there is no JWE apu); pass mdocSessionTranscript explicitly for them.
Without a transcript, the mDOC parser fails closed (claims: {}, no docType on the failure result). DCQL matching against that doctype-less parse can then surface a misleading doctype_mismatch even when the wallet sent the correct credential — prefer parsed.error when diagnosing.
For direct_post.jwt auto-build (openid4vp-1.0 / legacy Annex B), see mDOC SessionTranscript on the encrypted path.
Encrypted (direct_post.jwt)
The wallet wraps the envelope in a JWE. Decrypt explicitly so you can check state against the decrypted envelope before verification runs:
import {
decryptAuthorizationResponse,
verifyAuthorizationResponse,
} from "@openeudi/openid4vp";
const decrypted = await decryptAuthorizationResponse(
form.get("response"), // the JWE string
verifierEncryptionPrivateKey,
);
if (decrypted.state !== submittedState) {
throw new Error("state mismatch — possible CSRF / replay");
}
const result = await verifyAuthorizationResponse(decrypted, dcqlQuery, {
trustStore,
nonce,
clientId: verifierClientId, // also the expected KB-JWT `aud`
});If the wallet answered with an OpenID4VP 1.0 §8.5 error response (for example the user declined), verifyAuthorizationResponse throws WalletErrorResponseError with the wallet's code (access_denied, wallet_unavailable, …), description and state — encrypted or not.
verifyAuthorizationResponse also accepts the JWE directly via { response: jwe } together with options.decryptionKey — but that path makes the state check easy to skip, since the caller never holds the decrypted envelope. Prefer the explicit two-step pattern above.
verifyAuthorizationResponse accepts the OpenID4VP 1.0 §8.1 envelope shape: vp_token is always an object keyed by DCQL credential query id, with arrays of presentations. This release supports single-credential single-presentation only — multi-credential queries or multi-presentation arrays throw MultipleCredentialsNotSupportedError.
mDOC SessionTranscript on the encrypted path
Verifying an mDOC credential requires the ISO 18013-5 SessionTranscript the device signed over (see mDOC below). When the presented credential is mso_mdoc and clientId, responseUri and nonce are passed, verifyAuthorizationResponse auto-builds it in the profile selected by options.sessionTranscriptProfile:
'openid4vp-1.0'(default) — the OpenID4VP 1.0 FinalOpenID4VPHandovertranscript (§B.2.6.1). Fordirect_post.jwtalso passverifierEncryptionJwk(the response-encryption public JWK you sent in the request); its thumbprint is part of the transcript, and an encrypted mDOC response without it throwsMissingVerifierEncryptionKeyError. For unencrypteddirect_postthe thumbprint isnullandverifierEncryptionJwkis ignored. This profile does not read the JWEapuheader.const result = await verifyAuthorizationResponse(envelope, dcqlQuery, { trustedCertificates: [issuerCertDer], nonce, decryptionKey: verifierEncryptionPrivateKey, clientId: verifierClientId, responseUri: verifierResponseUri, verifierEncryptionJwk, // verifier's response-encryption public JWK });'oid4vp-draft-annex-b'— the legacy ISO 18013-7 Annex BOID4VPHandover, for draft-era (ID2/ID3) wallets. The library derives themdoc-generated-noncefrom the JWEapuheader, so this works fordirect_post.jwtonly.'iso-18013-7'is a deprecated alias (it was the default up to 0.13.0).const result = await verifyAuthorizationResponse(envelope, dcqlQuery, { trustedCertificates: [issuerCertDer], nonce, decryptionKey: verifierEncryptionPrivateKey, clientId: verifierClientId, responseUri: verifierResponseUri, sessionTranscriptProfile: 'oid4vp-draft-annex-b', });The 1.0 transcript is
[null, null, ["OpenID4VPHandover", SHA-256(cbor([client_id, nonce, jwk_thumbprint | null, response_uri]))]], wherejwk_thumbprintis the RFC 7638 SHA-256 thumbprint ofverifierEncryptionJwk(ornullwhen the response is unencrypted). Callers who need this transcript outsideverifyAuthorizationResponsecan use the exportedbuildOpenID4VPHandoverSessionTranscript({ clientId, nonce, responseUri, verifierEncryptionJwk? }).
If you already have the transcript bytes (or are verifying an mDOC outside either auto-build path), pass options.mdocSessionTranscript: Uint8Array explicitly — it always takes precedence over either auto-built value. buildOid4vpSessionTranscript({ clientId, responseUri, nonce, mdocGeneratedNonce }) (Annex B) and buildOpenID4VPHandoverSessionTranscript({ clientId, nonce, responseUri, verifierEncryptionJwk? }) (1.0-Final) are both exported for callers who need to construct a transcript themselves. Without a transcript, the mDOC parser fails closed — see mDOC. For the plain direct_post pattern, see Unencrypted (direct_post).
Supported JWE algorithms
direct_post.jwt decryption supports:
alg:ECDH-ES(driven by the encryption JWK'salgparameter)enc:A128GCM,A256GCM(HAIP requires both)
Other algorithms throw UnsupportedJweError.
ParseOptions / VerifyOptions
Both parsePresentation and verifyPresentation accept:
nonce(required) — the nonce bound into the VP token at creation time.audience?— the verifier's Client Identifier, which the KB-JWTaudmust equal (OpenID4VP 1.0 Appendix B.3.6). Required whenever a KB-JWT is verified —MissingAudienceErroris thrown otherwise.verifyAuthorizationResponsedefaults it toclientId. An arrayaudis rejected.verifyDcApiResponsealways usesorigin:<origin>instead (see Digital Credentials API).requireKeyBinding?— force SD-JWT holder-binding verification even when the issuer JWT carries nocnfclaim. When the credential is holder-bound (cnfpresent), a KB-JWT is always required regardless of this flag — fail-closed, not opt-in. Defaultfalsefor the parsers;verifyPresentation/verifyAuthorizationResponsealso require it whenever the matched query does not setrequire_cryptographic_holder_binding: false. See SD-JWT VC.kbJwtMaxAge?— largest accepted KB-JWT age in seconds, judged byiatand widened byclockSkewToleranceon both sides. Default 300.vctAncestors?— (verifyPresentation/verifyAuthorizationResponse) out-of-band SD-JWT VC type inheritance, e.g.{ "urn:eudi:pid:de:1": ["urn:eudi:pid:1"] }. See Verifying presentations against a query.mdocSessionTranscript?— CBOR bytes of the ISO 18013-5SessionTranscriptfor the current OpenID4VP exchange. Required to verify mDOC device authentication; the mDOC parser fails closed without it.verifyAuthorizationResponsecan build this for you fromclientId/responseUri— see mDOC SessionTranscript on the encrypted path. See mDOC.sessionTranscriptProfile?— (VerifyAuthorizationResponseOptionsonly — thedirect_post/direct_post.jwtauto-build described above) whichSessionTranscriptshape to build fromclientId/responseUri/nonce:'openid4vp-1.0'(default) builds the OpenID4VP 1.0 FinalOpenID4VPHandover;'oid4vp-draft-annex-b'(deprecated alias'iso-18013-7') builds the Annex B,apu-derived transcript for draft-era wallets. See mDOC SessionTranscript on the encrypted path.verifierEncryptionJwk?— (VerifyAuthorizationResponseOptionsonly) the verifier's response-encryption public JWK. Required for an encryptedmso_mdocresponse under the default'openid4vp-1.0'profile, to derive the handover's JWK thumbprint; ignored for unencrypted responses and for the Annex B profile.trustedCertificates?— DER-encoded issuer leaf certificates for the 0.4.x byte-equality trust check, used only whentrustStoreis unset. Deprecated since 0.5.0 — supplytrustStoreinstead. Optional since 0.14.0.trustStore?—TrustStoreinstance for full RFC 5280 chain validation (e.g.LotlTrustStore,StaticTrustStore, orCompositeTrustStore). When set, takes precedence overtrustedCertificates.The signer certificate (
x5c[0]/x5chain[0]) is trusted when it is an anchor (byte-identical DER) or chains to one through verified signatures; the remainingx5c/x5chainentries are untrusted path candidates and never anchors. A list longer than 10 certificates, signer included, is rejected as malformed before any entry is parsed. Anchors may therefore be roots — such as the IACA certificates an ETSI TS 119 602 PID-providers list publishes — or the document-signer certificate itself:import { StaticTrustStore } from "@openeudi/openid4vp"; // DER bytes of every certificate listed for the PID providers you accept: // IACA roots and directly-listed signer certificates alike. const trustStore = new StaticTrustStore(listedCertificates); await parsePresentation(vpToken, { trustStore, nonce, mdocSessionTranscript });Each link's signature, validity now and at issuance time (MSO
signed/ SD-JWTiat),basicConstraintscA +keyUsagekeyCertSign on every CA,digitalSignatureon the signer,pathLenConstraint,nameConstraintsand a 5-certificate maximum are enforced, and a path certificate with a critical extension the validator does not process (anything but basicConstraints, keyUsage, extKeyUsage, subjectAltName, nameConstraints, AKI/SKI, CRL distribution points, AIA and qcStatements) is rejected withreason: 'unrecognised_critical_extension'(RFC 5280 §4.2) — policy extensions included, since policy processing is not implemented. Formso_mdoc, built chains must also follow the ISO 18013-5 Annex B profile: the document signer carries EKU1.0.18013.5.1.2(reason: 'extended_key_usage'otherwise) andkeyUsageis mandatory. A signer that is itself the listed anchor is trusted by presence and exempt from that profile. Anchors are never matched by Subject DN alone.LotlTrustStorerequires anxmldsigjscrypto engine. Trusted lists are verified as signed XML, and this library does not pick a WebCrypto provider on your behalf. Register one once at startup, before any trusted-list fetch:import * as xmldsig from "xmldsigjs"; import { Crypto } from "@peculiar/webcrypto"; xmldsig.Application.setEngine("NodeJS", new Crypto());Omit it and
LotlTrustStorethrowsLotlConfigurationErroron first use, naming this as the cause.StaticTrustStoreis unaffected.mdocDocumentSignerEkus?— EKUs accepted on anmso_mdocDocument Signer when the chain is built to a CA anchor (at least one must be present). Default['1.0.18013.5.1.2']. A signer that is itself the anchor is not EKU-checked.revocationPolicy?—'skip'(default) |'prefer'|'require'. Controls whether the chain validator consults OCSP / CRL.statusPolicy?—'skip'(default) |'prefer'|'require'. Whether the SD-JWT VCstatusclaim, or the mdoc MSOstatus, is checked against its Token Status List. See Credential status (Token Status List).statusListResolver?— theStatusListResolverused whenstatusPolicyis not'skip'.fetcher?— HTTP transport for CRL/OCSP/LOTL fetches. Defaults to a guarded fetcher — see Network access. A fetcher you supply replaces the guards; wrap it withcreateGuardedFetcher({ fetch: yourFetcher })to keep them.cache?— cache for CRL/OCSP/LOTL artefacts. Defaults tonew InMemoryCache().clockSkewTolerance?— seconds of slack applied to certificate validity checks and to the KB-JWTiat/exp/nbfchecks. Default 60.trustedIssuerJwks?— opt-in alternate trust path for SD-JWT VCs whose issuer JWT lacks anx5cheader. The library matches bykid(or iterates the array when nokidis present) and skips chain validation entirely. Intended for harness setups (e.g. OIDF conformance suite) where the wallet signs withoutx5cand the verifier knows the signing key out-of-band. Not recommended for production verifiers —trustStoreis the secure path.allowedAlgorithms?— restrict signature algorithms. Defaults to['ES256','ES384','ES512'].skipTrustCheck?— skip trust checks entirely (dev/test only).expectedDocType?— for mDOC verification, lock the credentialdocType(or SD-JWTvct).allowLegacyVcSdJwtTyp?— deprecated. Also accept the legacyvc+sd-jwtissuer-JWTtyp, which draft-ietf-oauth-sd-jwt-vc-19 removed. Defaultfalse; only for interoperating with wallets that have not migrated yet. Removed in 1.0.0.
Network access
The library dereferences URLs only inside the trust module, and only when you opt into it:
| Fetch | When | Method |
|---|---|---|
| EU LOTL + national trusted lists | LotlTrustStore | GET |
| CRL distribution points | revocationPolicy 'prefer'/'require' | GET |
| OCSP responders (AIA) | revocationPolicy 'prefer'/'require' | POST |
| Token Status Lists (status.status_list.uri, SD-JWT VC and mdoc MSO) | statusPolicy 'prefer'/'require' | GET |
It does not fetch SD-JWT VC issuer metadata (/.well-known/jwt-vc-issuer), jwks_uri, Type Metadata (vct resolution — use aka_vcts or vctAncestors), or x5u. Issuer keys come from x5c or trustedIssuerJwks. If your application dereferences any of those itself, it is responsible for applying the SD-JWT VC draft-19 HTTP retrieval rules — createGuardedFetcher() (HTTPS-only by default) is exported for exactly that.
All trust-module fetches go through createGuardedFetcher({ allowHttp: true }) unless you pass fetcher; StatusListResolver defaults to the HTTPS-only createGuardedFetcher(). It enforces:
https:only by default (allowHttp: truefor the trust module: RFC 5280 CRLs and RFC 6960 OCSP are conventionally plain HTTP and every artefact is signature-verified); never anhttps→httpredirect downgrade.- No loopback, RFC 1918, CGNAT, link-local (incl.
169.254.169.254), ULA (incl.fd00:ec2::254), multicast, documentation or reserved targets — checked on the URL host and on every address it resolves to, including IPv4-mapped/compatible, NAT64 and 6to4 IPv6 forms.localhost/*.localhostare refused by name. A host that resolves to no addresses at all is refused asdns_resolution_failedrather than treated as public. - Redirects handled manually (
maxRedirects, default 3); each target is re-validated before it is requested, and credentials headers are dropped on cross-origin hops. - One timeout (
timeoutMs, default 15 000) for DNS, all hops and the body. - A size cap (
maxResponseBytes, default 16 MiB), enforced fromContent-Lengthand again while streaming.
Rejections throw GuardedFetchError with a reason (insecure_scheme, private_address, too_many_redirects, response_too_large, timeout, …).
import { createGuardedFetcher, LotlTrustStore } from "@openeudi/openid4vp";
const fetcher = createGuardedFetcher({ allowHttp: true, timeoutMs: 10_000, maxResponseBytes: 32 * 1024 * 1024 });
const trustStore = new LotlTrustStore({ signingAnchors, fetcher });Limits. DNS rebinding: the guard resolves and validates the host, then the transport resolves it again, so a hostile DNS server can answer differently the second time.
fetchgives no portable way to pin the validated address. If attacker-influenced URLs reach your verifier, also route egress through a filtering proxy, or inject afetchwhose connector checks the connected address (e.g. an undiciAgentwith a validatingconnect.lookup). In runtimes withoutnode:dns(browsers, some edge workers) only IP literals andlocalhostare checked unless you passlookup. Caching is handled by thecacheoption, not the fetcher.
Credential status (Token Status List)
Implements draft-ietf-oauth-status-list-21 for both credential formats. A credential whose issuer-signed data carries status.status_list: { idx, uri } is checked against the Status List Token at uri:
| Credential | Where the reference is | Status List Token fetched |
|---|---|---|
| SD-JWT VC (SD-JWT VC draft-19 §2.4, HAIP 1.0 §6.1) | status claim of the issuer-signed JWT | statuslist+jwt (§5.1) |
| mdoc (ISO/IEC 18013-5 MSO, draft-21 §6.3) | status element of the issuer-signed MSO | statuslist+cwt (§5.2) |
The same statusPolicy, statusListResolver and credentialStatus apply to both, on verifyPresentation, verifyAuthorizationResponse, verifyDcApiResponse and verifyAvPresentation. An mdoc reference is only ever resolved as a CWT, an SD-JWT VC reference only as a JWT; one resolver handles both.
import { StaticTrustStore, StatusListResolver, verifyAuthorizationResponse } from "@openeudi/openid4vp";
// Once per process: the cache must outlive a single presentation.
const statusListResolver = new StatusListResolver({
// Status-list SIGNER anchors — e.g. the PID-providers LoTE `PID/Revocation`
// services — kept separate from the issuance anchors.
trustStore: new StaticTrustStore(revocationServiceCertificates),
});
const result = await verifyAuthorizationResponse(envelope, query, {
trustedCertificates: [],
trustStore,
nonce,
statusPolicy: "require",
statusListResolver,
});
result.parsed.credentialStatus; // { outcome: 'checked', status: 'valid', value: 0, uri, idx, ... }Policy semantics (mirrors revocationPolicy):
| Situation | 'skip' (default) | 'prefer' | 'require' |
|---|---|---|---|
| Status VALID (0x00) | not checked | accepted | accepted |
| INVALID (0x01), SUSPENDED (0x02), any other value | not checked | valid: false | valid: false |
| idx out of range of an authentic list (§8.3 step 6) | not checked | valid: false | valid: false |
| status present but malformed / no status_list (HAIP §6.1) | not checked | valid: false | valid: false |
| No statement possible: list unreachable, untrusted signer, bad signature, expired, sub mismatch, malformed list | not checked | accepted, credentialStatus.outcome: 'unavailable' + warning | throws StatusListError |
| No status claim | not checked | accepted, outcome: 'absent' | accepted, outcome: 'absent' |
The status check runs only after the credential itself has fully validated (signature, trust, disclosures, key binding): a credential that is already invalid never triggers a fetch (§8.3). statusPolicy other than 'skip' without a statusListResolver throws StatusListError (reason: 'configuration'). The default stays 'skip' in 0.x for backward compatibility; it is intended to become 'prefer' in 1.0. For PIDs, use 'require' unless a documented ARF VCR_14 risk analysis allows 'prefer'.
What the resolver verifies for each Status List Token:
- fetched with
GETandAccept: application/statuslist+jwt(mdoc:application/statuslist+cwt) through the guarded fetcher (HTTPS only, no private targets, bounded redirects, 15 s, 16 MiB); - JWT: compact JWS with
typ: statuslist+jwtand an allowedalg(ES256/384/512 by default), signed byx5c[0]; - CWT: COSE_Sign1 (untagged,
#6.18or#6.61(#6.18)) withtyp(16)application/statuslist+cwtandalg(1, ES256/384/512) in the protected header, an attached payload, signed byx5chain[0](33, protected or unprotected but not both); - the
x5c/x5chainchain validates (RFC 5280) against the resolver'strustStore; anchors are never taken from the token, and an anchor found inside the chain is refused (setacceptPinnedSigner: trueonly if your anchor is the signer certificate itself); sub(CWT 2) equals the referenceduri;iat(6) present and not in the future;nbf(5) /exp(4) honoured withclockSkewTolerance(60 s);ttl(65534), if present, is a positive number; optionalmaxListAgeSecondsfreshness limit oniat;status_list(CWT 65533):bits∈ {1, 2, 4, 8},lstZLIB/DEFLATE — base64url text in a JWT, a raw byte string in a CWT — decompressed under a 16 MiB cap (maxListBytes).
Caching. Verified lists are cached in memory by format and uri until ttl elapses — bounded to [minTtlSeconds, maxTtlSeconds] (default 60 s – 24 h, defaultTtlSeconds 300 s when the token has no ttl) — and never past exp. The cache is LRU-bounded (maxCacheEntries, default 100); concurrent lookups of one uri share one fetch; failures are not cached. Call statusListResolver.prefetch(uri) (mdoc lists: prefetch(uri, { format: 'cwt' })) on a schedule to refresh lists out of band (ARF VCR_13–VCR_15) rather than on the presentation path. clock is injectable for tests.
Separate anchors. Status List Tokens are signed by a status issuer, which in the EUDI trust framework is published as its own service type (TS 119 602 SvcType/PID/Revocation), not as an issuance service. Give the resolver those anchors; do not reuse the credential trustStore unless the issuer demonstrably signs its lists with its issuance chain (then combine both with CompositeTrustStore). The chain profile is generic: the id-kp-oauthStatusSigning EKU (§10) is not required, because its OID is still unassigned.
Not yet covered: Status List Aggregation (§9) and historical resolution (§8.4, time parameter) are not implemented; status-list keys must come from x5c / x5chain (no kid / jwks resolution); a CWT typ given as a CoAP Content-Format number is refused (none is registered yet).
Digital Credentials API
createDcApiRequest and verifyDcApiResponse implement OpenID4VP over the W3C Digital Credentials API (OpenID4VP 1.0 Final Appendix A, HAIP 1.0 §5.2). The browser mediates the wallet call, so there is no QR code, no request_uri and no response_uri: the server builds a request, the frontend passes it to navigator.credentials.get(), and the frontend posts the result back to the server for verification. This library only runs on the server side.
Supported protocols (DigitalCredentialGetRequest.protocol):
| Protocol | Built by | Notes |
| --- | --- | --- |
| openid4vp-v1-unsigned | protocol: 'openid4vp-v1-unsigned' | §A.3.1. Parameters in clear; client_id omitted. The browser's origin authenticates the verifier. |
| openid4vp-v1-signed | protocol: 'openid4vp-v1-signed' | §A.3.2.1. JWS Request Object in data.request, with client_id (x509_hash by default, as HAIP requires) and the REQUIRED expected_origins. Same certificate checks as createSignedAuthorizationRequest, including trust_anchor_in_chain. |
| openid4vp-v1-multisigned | — | Not supported (JWS JSON Serialization). Rejected with unsupported_protocol. |
| org-iso-mdoc | — | Not supported yet (ISO/IEC 18013-7 Annex C). See below. |
Both variants accept verifierInfo (validated as for createSignedAuthorizationRequest, DcApiRequestBuildError('invalid_verifier_info')) and emit it as verifier_info: inside the Request Object when signed, as a plain data member when unsigned. Wallets enforcing ETSI TS 119 472-2 or HAIP expect it in a signed request.
The library only emits protocol strings from the W3C closed DigitalCredentialPresentationProtocol set (exported as DC_API_PRESENTATION_PROTOCOLS). Browsers reject other strings.
Server: build the request
import { createDcApiRequest, buildHaipQuery } from "@openeudi/openid4vp";
// Ephemeral response-encryption key per request (dc_api.jwt is the default and HAIP's requirement).
const encKeys = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const encryptionJwk = { ...(await crypto.subtle.exportKey("jwk", encKeys.publicKey)), alg: "ECDH-ES" };
const dcRequest = await createDcApiRequest(
{
protocol: "openid4vp-v1-unsigned", // or 'openid4vp-v1-signed' + expectedOrigins, signer, certificateChain
nonce: crypto.randomUUID(),
encryptionKey: { publicJwk: encryptionJwk },
vpFormatsSupported: { "dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"], "kb-jwt_alg_values": ["ES256"] } },
},
buildHaipQuery({ credentialId: "pid", format: "dc+sd-jwt", vctValues: ["urn:eudi:pid:1"], claims: ["birthdate"] }),
);
// Keep nonce, the encryption key pair and the query in the server-side session.
// Send dcRequest.request (JSON) to the browser.Browser: call the wallet (integrator code; not part of this package)
const request = await fetch("/dc-api/request").then((r) => r.json()); // = dcRequest.request
const credential = await navigator.credentials.get({
mediation: "required",
digital: { requests: [request] },
});
await fetch("/dc-api/response", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ protocol: credential.protocol, data: credential.data }),
});Feature-detect with typeof DigitalCredential !== "undefined" and, where available, DigitalCredential.userAgentAllowsProtocol("openid4vp-v1-unsigned"). Fall back to the redirect / QR flow otherwise.
Server: verify the response
import { verifyDcApiResponse, WalletErrorResponseError } from "@openeudi/openid4vp";
try {
const result = await verifyDcApiResponse(body, session.dcqlQuery, {
origin: "https://shop.example", // from configuration, never from the request
nonce: session.nonce,
responseMode: "dc_api.jwt",
protocol: "openid4vp-v1-unsigned",
decryptionKey: session.encKeys.privateKey,
verifierEncryptionJwk: session.encryptionJwk, // needed for mso_mdoc
trustStore,
trustedCertificates: [],
});
if (!result.valid) throw new Error(result.parsed.error ?? "presentation rejected");
} catch (err) {
if (err instanceof WalletErrorResponseError) {
// e.g. err.code === 'access_denied' — the user declined in the wallet
}
throw err;
}What verifyDcApiResponse enforces:
protocolis one of the two supported values, and equalsoptions.protocolwhen set (DcApiResponseError:unsupported_protocol,protocol_mismatch).- A wallet error object (
data: { error }, §A.4, or{ error }inside a decrypteddc_api.jwtpayload) throwsWalletErrorResponseError(code,description,state,uri) — the same class asverifyAuthorizationResponse. - The envelope matches
responseMode(defaultdc_api.jwt): an unencryptedvp_tokenin reply to adc_api.jwtrequest throwsunencrypted_response; a JWE in reply todc_apithrowsunexpected_encrypted_response. Decryption uses the same ECDH-ES + A128GCM/A256GCM allow-list asdirect_post.jwt. - SD-JWT VC: the KB-JWT
audmust beorigin:<origin>(§A.4, also for signed requests: theclient_idis never the audience over the DC API) andnoncemust match. A KB-JWT is required by default (requireKeyBindingdefaults totruehere), because it is the only thing that binds an SD-JWT presentation to the origin. - mDOC: the device signature must cover the
OpenID4VPDCAPIHandoverSessionTranscript[null, null, ["OpenID4VPDCAPIHandover", sha256(cbor([origin, nonce, jwkThumbprint | null]))]](§B.2.6.2), built automatically. The thumbprint is that ofverifierEncryptionJwkfordc_api.jwtandnullfordc_api.buildOpenID4VPDCAPIHandoverSessionTranscriptis exported. - There is no
stateover the DC API (§A.2). Bind the response to your session with thenonce, and use each nonce once. - The other
VerifyOptions(trustStore,statusPolicy/statusListResolver,vctAncestors, …) apply as forverifyAuthorizationResponse;audienceis derived fromorigin, and a conflicting value throwsaudience_conflict.
origin must be the serialized origin exactly as the browser sends it (https://shop.example, no path or trailing slash). http: is accepted only for localhost / loopback, since the API needs a secure context. For a signed request listing several expected_origins, pass the origin the page was actually served from.
Not in this release:
org-iso-mdoc(ISO/IEC 18013-7 Annex C, phase 2). The EU age-verification profile uses this protocol as its default DC API transport.- Zero-knowledge proofs of age (for example Longfellow, phase 3). They are only available over that transport and probably belong in a separate package.
openid4vp-v1-multisigned,transaction_dataand multi-credential responses.
Browser status (checked 2026-10-20; re-check before relying on it):
- Chromium plans to stop passing unregistered protocol strings to the platform. Its Intent to Ship holds the removal until the start of 2027. Requests built by this library already use registered strings only.
- WebKit renamed the IDL dictionary
DigitalCredentialRequesttoDigitalCredentialGetRequestto follow the spec (WebKit bug 304311). The JavaScript object shape ({ protocol, data }) is unchanged.
EU Age Verification (AV app) profile
createAvAuthorizationRequest and verifyAvPresentation implement the OpenID4VP fallback of the EU Age Verification technical specification, Annex A §A.6. Use it to accept a Proof of Age attestation from an AV app (the EU "mini wallet") by deep link or QR code. What the profile fixes:
- The request goes to the AV app as plain query parameters,
av://?…by default ("at least a custom URL scheme av:// MUST be supported"). It is not signed and there is norequest/request_uri("support for JAR is not required"). response_type=vp_token,response_mode=direct_post(unencrypted),client_id=redirect_uri:<response_uri>, anonce, and an optionalstate.- The DCQL query asks for one
mso_mdoccredential of doctypeeu.europa.ec.av.1and claim["eu.europa.ec.av.1", "age_over_18"](or anotherage_over_NN). - Relying Parties do not register and need no certificate: "The registration of Relying Parties that request age verification … is not required." TLS on
response_uriis the only verifier authentication.
Server: build the request
import { createAvAuthorizationRequest } from "@openeudi/openid4vp";
const avRequest = createAvAuthorizationRequest({
responseUri: `https://shop.example/av/response/${sessionId}`, // https only
state: sessionId, // optional per Annex A; the reference verifier always sends one
// ageThreshold: 18, nonce: generated (256 bits), baseUri: "av://"
});
// Persist everything verification needs, keyed by the session.
await sessions.save(sessionId, {
responseUri: avRequest.responseUri,
nonce: avRequest.nonce,
state: avRequest.state,
ageThreshold: avRequest.ageThreshold,
});
// Render avRequest.uri as a QR code (desktop) or a link (same device):
// av://?response_type=vp_token&response_mode=direct_post&client_id=redirect_uri%3Ahttps%3A%2F%2F…client_metadata carries vp_formats_supported for mso_mdoc (ES256/ES384/ES512, as the reference verifier advertises), because OpenID4VP 1.0 §5.9.3 passes all verifier metadata that way under redirect_uri:. Pass vpFormatsSupported: null to leave it out, like the Annex A example.
Server: verify the AV app's POST to response_uri
import {
verifyAvPresentation,
StaticTrustStore,
WalletErrorResponseError,
} from "@openeudi/openid4vp";
// Anchors of authorised Attestation Providers from the Commission's AV trusted
// list. Keep them apart from PID-provider anchors.
const avTrustStore = new StaticTrustStore(avProviderCertificatesPem);
app.post("/av/response/:sessionId", express.urlencoded({ extended: false }), async (req, res) => {
const persisted = await sessions.take(req.params.sessionId); // single use
try {
const result = await verifyAvPresentation(req.body, { request: persisted, trustStore: avTrustStore });
await sessions.complete(req.params.sessionId, { ageVerified: result.ageVerified, failure: result.failure });
res.json({}); // §8.2: may return { redirect_uri } for same-device flows
} catch (err) {
if (err instanceof WalletErrorResponseError) {
// user declined (access_denied) or the app failed
}
res.status(400).json({ error: "invalid_request" });
}
});What verifyAvPresentation checks:
statematches the request, when the request had one.vp_tokenholds exactly one presentation, under the requested credential id. Otherwise it throwsAvResponseError. A wallet error response (error=access_denied, …) throwsWalletErrorResponseError.- The
DeviceResponsehas exactly one document, withdocTypeeu.europa.ec.av.1and status 0. The MSOdocTypemust match too. - Data model (§A.4.2): "A Proof of Age Attestation SHALL NOT include any other attribute." Any namespace other than
eu.europa.ec.av.1, or any element other than a booleanage_over_NN, rejects the presentation (failure: 'unexpected_attribute'/'invalid_attribute_value'). It is not ignored: such a credential does not conform to the data model, and accepting it would mean processing personal data the RP never asked for. Device-signed elements are rejected too. - Issuer signature, digests and MSO validity. The issuer chain must validate against
trustStore. A Document Signer under a CA anchor must carry EKU1.3.130.2.0.0.1.2(the AV reference issuer's) or1.0.18013.5.1.2(ISO 18013-5); override withdocumentSignerEkus. Revocation is skipped by default ("Attestation revocation is not required"). - Device authentication over the OpenID4VP 1.0
OpenID4VPHandoverSessionTranscript[clientId, nonce, null, responseUri](§B.2.6.1,nullbecause the response is not encrypted). It is byte-identical to the Annex A example transcript. - With
statusPolicy: 'prefer' | 'require'and astatusListResolver, the MSOstatus(statuslist+cwt) when the attestation carries one (failure: 'credential_status'for a non-VALID status). Proof of Age attestations normally carry none and still verify (credentialStatus.outcome: 'absent'). Default'skip'. - The requested
age_over_NNis disclosed (failure: 'missing_attribute'otherwise).
Result: { valid, ageVerified, ageOver, ageThreshold, ageOverAttribute, issuer, trust, state, credentialStatus?, failure?, error? }. Gate access on ageVerified. valid: true with ageOver: false is a genuine attestation that says the holder is not over the threshold. Trust failures throw TrustAnchorNotFoundError / CertificateChainError, as everywhere else in this library.
Use each nonce once. The spec recommends treating a plain-mdoc Proof of Age attestation as single use; the AV app enforces that, and nonce binding prevents replay to your verifier.
Not covered: the W3C Digital Credentials API transport with org-iso-mdoc and zero-knowledge proofs (Annex A §A.8, the profile's preferred mechanism), and issuance. The request carries no verifier_info: it is unsigned under redirect_uri:, so nothing could bind it to the verifier.
Known interop issue: the AV reference implementation's certificates (the Annex A §A.11 Document Signer and the "Age Verification Issuer CA 01" bundled in the AV Android app) encode IssuerAltName as a nested Extension instead of GeneralNames. @peculiar/x509 cannot parse it, so StaticTrustStore and chain validation throw on those certificates. RFC 5280 §4.2 requires a recognised extension to be processed, so this library fails closed rather than skipping it.
Supported formats
SD-JWT VC
Selective Disclosure JSON Web Token Verifiable Credentials. The token is a string in jwt~disclosure~kb format. The parser:
- Requires the issuer JWT
typto bedc+sd-jwt(draft-ietf-oauth-sd-jwt-vc-19); the legacyvc+sd-jwtis rejected unlessallowLegacyVcSdJwtTypis set - Decodes the issuer JWT and extracts the
x5ccertificate chain - Verifies the issuer certificate against your trusted set
- Checks credential expiry from the
expclaim - Resolves selective disclosures with the issuer's
_sd_alg(sha-256by default;sha-384andsha-512also supported, anything else rejected). Disclosures must be base64url; a disclosure sent twice, or a digest that appears more than once, is rejected (RFC 9901 §7.1) - Enforces holder binding (key binding JWT / KB-JWT): mandatory whenever the issuer JWT carries a
cnfclaim — the credential is holder-bound and verification fails closed if the KB-JWT is missing. SetrequireKeyBinding: trueto extend this requirement to credentials withoutcnf(the query-level default does this for you inverifyPresentation). When a KB-JWT is present, the parser verifies its signature against thecnf.jwkholder key and checks RFC 9901 §7.3:typiskb+jwt;iat,nonce,sd_hashandaudare present;nonceequalsoptions.nonce;audis a single string equal tooptions.audience;sd_hashmatches; andiatis no older thankbJwtMaxAge(300 s) nor in the future, give or takeclockSkewTolerance.
Breaking change (0.9.0): prior releases only validated the KB-JWT's nonce when a KB-JWT happened to be present and silently accepted holder-bound credentials presented without one. As of 0.9.0, a holder-bound credential missing its KB-JWT is rejected outright — see CHANGELOG.
mDOC
Mobile Document credentials as defined in ISO 18013-5. The token is a CBOR-encoded Uint8Array containing a DeviceResponse. The parser:
- Decodes the CBOR DeviceResponse structure
- Extracts the issuer certificate from the COSE_Sign1
issuerAuth(x5chain label 33) - Verifies the certificate against your trusted set — with
trustStore, by building a chain from the document signer to an anchor such as an IACA (seetrustStore) - Checks the validity period from
validityInfo - Extracts the claims of every namespace (into
claims, and grouped by namespace intonamespacedClaims) - Verifies each
IssuerSignedItem's digest against the MSO'svalueDigests, computed over the full tag-24IssuerSignedItemBytes(#6.24(bstr .cbor IssuerSignedItem)) per ISO 18013-5 §9.1.2.4 — not just the inner CBOR — matching how real wallets encode mdocs. mDOC verification, including this digest check and device authentication below, is validated against the OIDF conformance suite acting as an independent ISO 18013-5 mdl wallet. - Performs full ISO 18013-5 §9.1.3 device authentication: verifies the
DeviceSignature(COSE_Sign1) overDeviceAuthentication, which binds theSessionTranscript/nonce, using the EC2 device key committed in the MSO'sdeviceKeyInfo.DeviceMac(COSE_Mac0) is not supported and is rejected. The parser fails closed ifdeviceSigned, theSessionTranscript(options.mdocSessionTranscript), or the device key is missing — a capturedissuerSignedpayload alone is no longer accepted as proof of presentation.
Breaking change (0.9.0): prior releases verified only the issuer-signed data (
issuerSigned), so a replayed or intercepted mDOC device response — without any proof the presenting device held the credentialed key — was accepted. As of 0.9.0, device authentication is mandatory and fails closed; see CHANGELOG.
Parsers
parsePresentation, verifyPresentation and verifyAuthorizationResponse use a fixed pair of parsers — SdJwtParser and MdocParser, both exported for direct use. There is no API to register another format; ICredentialParser is exported as the type those two implement.
PresentationResult
| Field | Type | Description |
| ------------------ | ----------------------- | --------------------------------------------------------------------------- |
| valid | boolean | Whether the credential passed all checks |
| format | CredentialFormat | 'sd-jwt-vc' or 'mdoc' |
| claims | CredentialClaims | Every disclosed claim by name; treat as Record<string, unknown> |
| issuer | IssuerInfo | Issuer certificate; country is a TLD guess for SD-JWT and '' for mDOC |
| vct | string? | SD-JWT VC type |
| akaVcts | string[]? | Issuer-signed aka_vcts (SD-JWT VC) |
| docType | string? | mDOC docType |
| namespacedClaims | Record<…>? | mDOC claims grouped by namespace |
| trust | TrustEvaluationResult?| Chain, anchor, revocation, provenance, trustedAuthorities (with trustStore) |
| credentialStatus | CredentialStatusResult? | Token Status List outcome when statusPolicy is 'prefer' / 'require' (SD-JWT VC and mdoc) |
| error | string? | Reason for failure when valid is false |
CredentialClaims still declares age_over_18, age_over_21, nationality, resident_country and family_name_birth, deprecated: an EU PID has none of the age fields, and its SD-JWT VC form uses nationalities (an array). They go in 1.0.0.
Error types
| Error class | Default message | Thrown when |
| -------------------------- | ----------------------------------------- | -------------------------------------------------- |
| InvalidSignatureError | Credential signature validation failed | Signature verification fails |
| ExpiredCredentialError | Credential has expired | Credential exp or validUntil is in the past |
| UnsupportedFormatError | Unsupported credential format: {format} | Token format is not SD-JWT VC or mDOC |
| MalformedCredentialError | Credential structure is malformed | Token cannot be decoded or is structurally invalid |
| NonceValidationError | Nonce doe
