By Olivier Meunier — independent developer building Tessaliq, an EUDI Wallet verifier focused on age verification under the French ARCOM framework.
TL;DR
Over two sessions in April 2026, I walked the Tessaliq verifier — an EUDI Wallet verifier I have been building on a personal basis — through the OpenID Foundation self-certification suite for OpenID for Verifiable Presentations 1.0 Final. The first session, on 9 April, landed me at what I thought was 9/9 passed on the sd_jwt_vc + x509_san_dns + request_uri_signed + plain_vp + direct_post.jwt variant. The second session, on 10 April — triggered by an unrelated refactor and a lingering sense that a claim this load-bearing deserved a fresh run — found two more bugs and turned that 9/9 into an honest 9/9 for the first time. The 10th module, invalid-session-transcript, is mdoc-only and not applicable to an SD-JWT-VC configuration.
Tessaliq is a privacy-preserving identity verifier I have been developing on a personal basis for European SaaS publishers subject to age verification obligations. The production path today is mdoc attribute check aligned with the EU Age Verification blueprint profile eu.europa.ec.av.1; the OID4VP 1.0 Final work described in this post sits on the SD-JWT-VC side of the verifier and covers both the attribute-check flow and the selective-disclosure flow.
This post is a concrete retrospective: the bugs I hit, the spec ambiguities I had to resolve by reading the actual test client code, and the fixes I shipped. If you’re implementing a verifier against OID4VP 1.0 Final, I hope this saves you a few hours.
Disclaimer. Passing self-certification is not the same as being OIDF-certified. Certification requires going through an accredited lab (OIDF labs program, roughly Q2 2026 availability). Self-certification is the preliminary step: you run your implementation against the public conformance tool and submit the passing log. Tessaliq has not (yet) gone through the lab accreditation, and is not an incorporated entity yet. I am writing this as an individual implementer, not as a certified or commercial party.
1. What the OIDF conformance suite actually tests
The OpenID Foundation runs a public conformance tool at demo.certification.openid.net. For OpenID4VP 1.0 Final, you configure a “test plan” scoped to the variant your verifier supports. A variant is a tuple like:
credential_format:sd_jwt_vcoriso_mdlclient_id_prefix:x509_san_dns,redirect_uri,x509_hash(this parameter was renamed fromclient_id_schemeduring the 1.0 Final cycle — details in §10)request_method:url_queryorrequest_uri_signedvp_profile:plain_vporhaip(the High Assurance Interoperability Profile for EU Digital Identity Wallets). DCQL is implicit in 1.0 Final — there is no longer a separatequery_languageparameterresponse_mode:direct_postordirect_post.jwt
The OIDF acts as a wallet in the flow. You, the implementer, are the verifier. The tool drives your endpoint, feeds you well-formed and intentionally malformed credentials, and checks that your verifier behaves exactly as the spec says at every step.
For Tessaliq, the variant I locked is:
sd_jwt_vc + x509_san_dns + request_uri_signed + plain_vp + direct_post.jwt
This is the most conservative modern SD-JWT-VC path: signed request object, DCQL query, encrypted response. 9 modules apply to this variant. (A note on the choice of x509_san_dns over x509_hash — which HAIP 1.0 Final later mandated — appears at the end of §13.)
2. The 9 modules, in plain English
| Module | What it asserts | Why it matters |
|---|---|---|
happy-flow | A valid end-to-end exchange returns HTTP 200 | Obvious, but also a trap — see §3 |
request-uri-method-post | POST /request_uri/:id works the same as GET | Some wallets prefer POST to carry state |
invalid-credential-signature | A tampered issuer signature → HTTP 400 | The whole trust chain hinges on this |
invalid-kb-jwt-signature | A tampered KB-JWT signature → HTTP 400 | Holder binding must be cryptographically enforced |
invalid-kb-jwt-nonce | A nonce mismatch → HTTP 400 | Replay protection |
invalid-kb-jwt-aud | An audience mismatch → HTTP 400 | Cross-verifier replay protection |
invalid-sd-hash | A disclosure whose hash isn’t in _sd → HTTP 400 | Selective disclosure integrity |
kb-jwt-iat-in-past | A stale iat (>1h) → HTTP 400 | Freshness |
kb-jwt-iat-in-future | A future iat (>60s skew) → HTTP 400 | Clock discipline |
Each module triggers exactly one deviation from the happy path and expects a specific HTTP-level answer. No module is “hard” in isolation. What’s hard is shipping a verifier where all nine behave correctly simultaneously — because some fixes for one test accidentally break another if you’re not careful.
3. Trap #1 — HTTP 200 with an internal error is a happy-flow killer
Symptom. happy-flow passed. All negative tests failed with a message like EnsureHttpStatusCodeIs4xx: expected 4xx, got 200.
Root cause. The initial /v1/openid4vp/response endpoint always answered { ok: true } with HTTP 200, then recorded the actual validation result (success or failure) in a session record for the dashboard to read later. That was fine for the in-house SDK, but the OIDF tool assumes the spec §8.2 behavior: if the credential is invalid, the HTTP response itself is 4xx.
Fix. One helper, sendResult(reply, body, status), used everywhere in the response handler. Every negative branch now returns 400 with a JSON body. The happy path returns 200 with {}. The dashboard still reads session state from the database; the HTTP status is purely for the wallet.
The subtle part: the happy-flow test also relies on this, because if your verifier rejects a valid credential internally but returns 200, the tool logs it as “passed” while your internal state is actually corrupt. One run had happy-flow green but the session record showed rejected: invalid disclosure hash. False positive. The fix cleaned that up too.
Takeaway. HTTP status codes are part of the verifier’s API surface, not an implementation detail. Treat them as normative.
4. Trap #2 — Hashing disclosures by re-serializing is silently wrong
Symptom. invalid-sd-hash kept passing, but happy-flow randomly failed ~30% of the time with “disclosure digest mismatch”.
Root cause. SD-JWT disclosures are base64url-encoded JSON arrays that appear verbatim in the compact form between ~ separators. The issuer computes SHA-256(base64url_original) and embeds that digest in _sd. To verify, the verifier must recompute SHA-256 over the exact same bytes it received.
The original code was doing:
// WRONG — re-serializes, may differ byte-for-byte from the original
const parsed = JSON.parse(base64urlDecode(disclosure));
const digest = sha256(base64urlEncode(JSON.stringify(parsed)));
JSON.stringify is not byte-preserving. Key order, whitespace, and number formatting can all differ between implementations. The hash matched when the issuer used the same serializer, and diverged the moment the OIDF tool re-ordered keys.
Fix. Keep the original encoded string around.
// RIGHT — hash the bytes we actually received
interface ParsedSdJwt {
// ...
encodedDisclosures: string[]; // the untouched base64url strings
decodedDisclosures: Disclosure[]; // the parsed form, for semantic use
}
for (const encoded of parsed.encodedDisclosures) {
const digest = base64urlEncode(sha256(encoded));
if (!knownDigests.has(digest)) {
return reject('invalid sd-hash');
}
}
Takeaway. If you find yourself calling JSON.stringify before hashing, stop and think. Cryptographic digests are over bytes, not over abstract values.
5. Trap #3 — _sd walking must recurse
Symptom. happy-flow failed on credentials where disclosures were nested (e.g. country inside place_of_birth, or entries inside nationalities).
Root cause. Only the top-level _sd array in the issuer-signed payload was being inspected. But SD-JWT supports nested selective disclosure: a _sd array can appear at any object depth, and for array members, the wire format is {"...": "<digest>"} placeholders.
Fix. A recursive helper.
function collectAllSdDigests(node: unknown, acc: Set<string>): void {
if (Array.isArray(node)) {
for (const item of node) {
if (typeof item === 'object' && item !== null && '...' in item) {
acc.add((item as { '...': string })['...']);
} else {
collectAllSdDigests(item, acc);
}
}
return;
}
if (typeof node === 'object' && node !== null) {
for (const [key, value] of Object.entries(node)) {
if (key === '_sd' && Array.isArray(value)) {
for (const d of value) acc.add(d as string);
} else {
collectAllSdDigests(value, acc);
}
}
}
}
Takeaway. Read draft-ietf-oauth-sd-jwt §4.2.5 (recursive disclosures) carefully. If you only handle top-level _sd, you’ll pass most fixtures but break on real credentials.
6. Trap #4 — KB-JWT audience must match the prefixed client_id
Symptom. invalid-kb-jwt-aud passed, but happy-flow started failing after I added audience validation.
Root cause. In OID4VP 1.0 Final, when you use a client identifier scheme like x509_san_dns, the client_id sent in the authorization request is prefixed with the scheme:
client_id = "x509_san_dns:api-staging.tessaliq.com"
The wallet binds the KB-JWT aud claim to that exact string. The verifier, when validating the KB-JWT, was comparing payload.aud to its baseUrl (the unprefixed host). Mismatch → reject → happy-flow fails.
Fix. Compute verifierClientId in the response route with the same prefixing logic as the request route, then compare strictly.
const scheme = config.clientIdScheme; // 'x509_san_dns'
const verifierClientId = `${scheme}:${config.baseHost}`;
if (payload.aud !== verifierClientId) {
return sendResult(reply, { error: 'invalid_aud' }, 400);
}
Takeaway. Client identifier schemes are part of the identity. Don’t strip the prefix internally — it is the client id.
7. Trap #5 — /request_uri must return JWT, not JSON
Symptom. The OIDF Java client consistently failed happy-flow with expected application/oauth-authz-req+jwt, got application/json.
Root cause. GET /request_uri/:id was defaulting to a JSON body because the in-house SDK’s debug UI renders it nicely. RFC 9101 §4 defines the media type application/oauth-authz-req+jwt for a signed Request Object, and §5.2.3 shows that media type in the example response when the object is fetched via request_uri. The OIDF client sends Accept: application/json as a generic header, but the Request Object must be delivered in its declared JWT form — a JSON-wrapped body breaks the consumer’s JWT parse step.
Fix. Default to JWT. Expose JSON behind an explicit ?format=json query parameter for local debug tooling.
app.get('/v1/openid4vp/request/:sessionId', async (req, reply) => {
const session = await loadSession(req.params.sessionId);
const jwt = await signRequestObject(session, privateKey);
if (req.query.format === 'json') {
return reply.header('content-type', 'application/json').send(session.requestObject);
}
reply.header('content-type', 'application/oauth-authz-req+jwt').send(jwt);
});
Takeaway. Content type is normative in OAuth/OpenID specs. Accept headers are suggestions, content types are contracts.
8. Trap #6 — direct_post.jwt response handling
Symptom. happy-flow on the encrypted response mode returned HTTP 415 Unsupported Media Type.
Root cause. Two compounding issues:
- Fastify wasn’t parsing
application/x-www-form-urlencoded.@fastify/formbodywasn’t registered. The OIDF tool posts the JWE as a form-encoded field, and without the plugin, Fastify just 415’d on the content type. - The JWE
kidfield. Indirect_post.jwtmode, thestateparameter is inside the encrypted payload, not a form field, so the request couldn’t be routed to the right session’s decryption key by readingstate. The JWE had to become self-identifying. Fix: setkidto thesessionIdin the verifier’s ephemeral encryption JWKS, so the server can pick the decryption key just by parsing the protected header.
function buildEncryptionJwks(publicJwk: Jwk, sessionId: string): Jwks {
return { keys: [{ ...publicJwk, kid: sessionId, use: 'enc' }] };
}
Takeaway. In direct_post.jwt, the only thing the server sees before decryption is the JWE protected header. Plan your key routing around that.
9. The seventh fix: iat validation is non-trivial
The three kb-jwt-iat-* modules turned out to be more nuanced than they look. The OIDF suite defines:
iatwithin 60 seconds ofnow→ accept (clock skew tolerance)iatmore than 60 seconds in the future → rejectiatmore than 1 hour in the past → reject
That last threshold isn’t in OID4VP 1.0 Final itself — it’s a practical freshness window that the OIDF test enforces. I picked exactly 3600 seconds and added unit tests for all three boundaries, including the exact 60s skew edge.
const ageSeconds = now - iat;
if (ageSeconds < -60) return reject('iat_in_future');
if (ageSeconds > 3600) return reject('iat_in_past');
Takeaway. Test the boundaries, not just the obvious cases. Off-by-one on >= vs > will silently fail one of the three modules.
10. The re-run that found two more bugs
I thought I was done on 9 April. I wasn’t.
The morning of 10 April, an unrelated refactor landed on staging — gating the zero-knowledge code path behind a runtime feature flag — whose blast radius brushed against lib/openid4vp.ts. The seven invariants from the previous week looked fine on a static read, and I almost moved on. Then I caught myself: “9/9 passed” is exactly the kind of claim that deserves a fresh dynamic run after any refactor touching the verifier path. I booked another hour, regenerated a conformance token, re-ran the plan module by module, and found two bugs that had been invisible to the first run and to every unit test I had ever written.
Trap #8 — A “passed” module that was actually skipped
Symptom. On re-run, oid4vp-1final-verifier-request-uri-method-post came back with the verdict:
The test was skipped: the verifier’s authorization request does not include
request_uri_method=post, so this test is not applicable.
Skipped, not failed. The OIDF reporting format buckets a skipped module as “not a failure” for the plan-level tally, which is why I had cheerfully counted it as a pass on 9 April. The memory I’d written about that run even listed it in the “9/9 passed” table with an invariant and a code reference, as if I had actually exercised it. I hadn’t. I had shipped the POST-side handler (app.post('/v1/openid4vp/request/:sessionId', ...)) but I had never made the verifier emit request_uri_method=post in its own authorization requests. The OIDF runner dutifully checked whether to POST or GET the request URI, saw nothing instructing it to POST, defaulted to GET, and skipped the POST-specific test.
Root cause. OID4VP 1.0 Final §5.10 lets the verifier signal to the wallet which HTTP method it wants on request_uri. The default is GET. If the verifier wants the wallet to POST (carrying wallet_metadata and wallet_nonce in the body), it must include request_uri_method=post as a query parameter in the authorization request. Our generateDeepLink never did.
Fix. One line, in the OIDF-only branch of the deep link generator.
const oidfEndpoint = process.env.OIDF_AUTHORIZATION_ENDPOINT;
if (oidfEndpoint) {
// Signal request_uri_method=post so the OIDF runner exercises the POST path.
// Without this, the runner defaults to GET and the dedicated test module
// is skipped as "not applicable to this variant".
return `${oidfEndpoint}?client_id=${clientId}&request_uri=${encodeURIComponent(requestUri)}&request_uri_method=post`;
}
return `openid4vp://authorize?client_id=${clientId}&request_uri=${encodeURIComponent(requestUri)}`;
I deliberately scoped the new parameter to the OIDF conformance branch rather than emitting it unconditionally, because not every production wallet in the wild supports the POST method yet and I did not want to discover that fact the first time a real user tried to verify something.
Takeaway. A conformance suite’s “not applicable” is not a free pass. If a module isn’t exercised, it isn’t a signal — and if you count it as one anyway, you’re silently inflating your own claim. Go read each module’s skip conditions and ask yourself: am I hitting them on purpose, or by omission?
Trap #9 — Silent fall-through triggering unexpected HTTP calls
Symptom. On re-run, oid4vp-1final-verifier-invalid-credential-signature failed immediately with:
FAILURE: Got unexpected HTTP call to .well-known/openid-credential-issuer
INTERRUPTED: Test was interrupted before it could complete.
The happy flow still passed, every other negative module passed, but this specific negative module could not even start the assertion it cared about. The runner saw Tessaliq make an HTTP request it did not expect, decided that any unsanctioned call was a contract violation, and interrupted the test.
Root cause. Two bugs compounded inside our issuer-registry.ts module, and neither showed up until a credential with a deliberately broken issuer signature hit the verifier.
First, verifyCredential had a silent fall-through. Its flow was:
- Try to verify using the locally configured mock issuer key (fast path for dev/test).
- If that fails, fall through to fetching a fresh JWKS from the issuer URL listed in the credential’s
issclaim. - If the JWKS fetch finds a matching key, use it.
That logic is defensible for a production deployment with real issuers that might rotate keys. It is dangerous in two ways for a verifier running against a conformance tool or any strict test harness. One, semantically, if the mock key is the authoritative source of truth (which it is in any environment that has MOCK_ISSUER_JWK set), then a credential whose signature doesn’t match the mock key is simply invalid. There is nothing to discover elsewhere. Two, operationally, the fall-through generates an outbound HTTP call to the iss URL, which in the conformance setup is the OIDF runner itself. The runner treats unknown-to-it inbound calls as broken contract.
Second, the JWKS-fetching helper probed the wrong well-known endpoint first. Its list of candidate URLs started with /.well-known/openid-credential-issuer, which is an OID4VCI (credential issuance) metadata endpoint. It has nothing to do with verification. For SD-JWT-VC, the standard issuer JWKS lives at /.well-known/jwks.json or, under the draft SD-JWT-VC well-known convention, at /.well-known/jwt-vc-issuer/<path>. The openid-credential-issuer endpoint was a leftover from an earlier iteration when I had confused the issuance and verification metadata paths, and it had quietly sat in the probe list since. In production, a real EUDI issuer probably wouldn’t even serve that path, so the probe would fail silently and move on to the next URL. On the OIDF runner, which does not serve it either, the probe itself was enough to trigger the unexpected-call failure.
Fix. Two surgical changes in the same file.
// In verifyCredential: don't fall through to JWKS fetch when the mock key
// is the authoritative source of truth for the configured issuer
try {
const { key, source } = await resolveIssuerKey(issuerUrl, header.kid);
const { payload: verified } = await jose.jwtVerify(issuerJwt, key, {
algorithms: ['ES256', 'EdDSA', 'RS256'],
});
return { valid: true, issuer: issuerUrl, source, payload: verified };
} catch {
// Mock key failed. In mock issuer mode, the mock key IS the source of
// truth. Do NOT fall back to a JWKS fetch against the issuer URL — it
// would (a) make spurious HTTP calls that break strict test runners,
// and (b) be semantically wrong: the credential is simply invalid.
if (isMockIssuer(issuerUrl)) {
return { valid: false, issuer: issuerUrl, source: 'mock', payload: {} };
}
}
// In fetchIssuerJwks: drop the /.well-known/openid-credential-issuer probe,
// which is an OID4VCI (issuance) endpoint, unrelated to verification and
// prone to triggering unexpected HTTP calls on strict test runners.
const urls = [
`${issuerUrl}/.well-known/jwks.json`,
`${issuerUrl}/jwks`,
];
Deploy, re-trigger the module, it passed.
Takeaway. A verifier’s hot path is never just the happy flow. Every error path — every catch block, every fallback, every “let’s try the next URL” — is part of the public behavior and is equally subject to the runner’s expectations. Silent fallbacks that generate outbound HTTP calls are the most dangerous kind of side effect, because they don’t show up in unit tests and they don’t show up in static review. They only show up when a strict external harness refuses to respond to them.
What the re-run taught me about re-running
A conformance run is not a one-shot ceremony — it is the right snapshot to take after any refactor that plausibly touches the verifier path. The cost is low: a fresh token is free, a plan rebuild takes two minutes, the full 9-module walk takes ten. Less than the time you spend arguing with yourself about whether the refactor really needs a re-test.
11. What the conformance suite does not test, and a few practical notes
Passing 9/9 does not make your verifier safe for production. Things the OIDF suite does not currently exercise on this variant: the real mso_mdoc branch (the mdoc variant has not been run yet, hence the invalid-session-transcript skip), X.509 trust chain beyond the single test IACA (no revocation, no CRL, no OCSP), replay detection at scale, rate limiting on /request_uri and /response, observability and audit log correctness. All of these are still on the implementer.
A few practical notes for someone starting now:
- Read the OIDF Java client source, not just the spec. When a test fails with a cryptic message, the test code tells you exactly what it expected.
- Create a fresh test instance for every re-run. FINISHED instances cannot restart; reusing a bad alias triggers
Illegal test state change, which retroactively flips passed modules to FAILED. - Pin your test issuer JWK at both ends. The
MOCK_ISSUER_JWKon your server andcredential.signing_jwkin the OIDF UI must be the same key. - Do not prefix
client_idin the OIDF UI. The tool addsx509_san_dns:automatically; pre-prefixing yields a double prefix andEnsureMatchingClientIdfails. - Re-run after refactors, not just after first-write. Treat a 9/9 result as a property of a specific commit against a specific runner version, not a property of your codebase.
13. What’s next
- Branching mdoc into the OIDF suite to exercise the
mso_mdocvariant andinvalid-session-transcript - Re-running the OIDF plan under the HAIP (High Assurance Interoperability Profile 1.0 Final) variant, switching the Client Identifier Prefix from
x509_san_dnstox509_hashand validating theclient_metadatadeclarations against the HAIP requirements - Automating the conformance run as a scheduled job so drift is caught within days, not whenever I remember to look
- Publishing follow-up posts on HPKE + JWE for the Digital Credentials API path, and on Tessaliq’s approach to double-anonymity age verification under the French ARCOM framework
If you’re implementing a verifier and hit anything weird that’s not covered here, open an issue or send a note to contact [at] tessaliq [dot] com. The more implementers compare notes publicly, the faster the whole EUDI verifier ecosystem gets to trustworthy interoperability.
About the author and Tessaliq
Olivier Meunier is an independent developer based in Angers, France. Tessaliq is the EUDI Wallet verifier he is building on a personal basis to confront the EUDI Wallet technical standards with the design decisions an implementer actually has to make. It is a pre-incorporation project — there is no company behind it yet — and the author is writing on a personal basis, not on behalf of an incorporated entity.
Tessaliq follows an open-core publication model. The verifier API, its policies, the OID4VP conformance code, and the dashboard live in a private repository; a set of technical components — the Noir zero-knowledge age verification circuits, the web SDK @tessaliq/sdk-web, the SD-JWT helper library, and the shared utilities — are published under the MIT license at github.com/Tessaliq/tessaliq-open.
The production verification path today is mdoc attribute check aligned with the EU Age Verification blueprint profile eu.europa.ec.av.1. An experimental zero-knowledge circuit built on Noir + Barretenberg is available as an alpha opt-in, currently gated behind a feature flag while a Barretenberg vulnerability (March 2026) and the Noir 1.0 migration are resolved upstream. Contact: contact [at] tessaliq [dot] com.
References
- OpenID for Verifiable Presentations 1.0 Final
- OpenID4VC High Assurance Interoperability Profile 1.0 Final (24 December 2025)
- SD-JWT VC draft-ietf-oauth-sd-jwt-vc-16
- IETF Token Status List draft-14
- RFC 9101 — JWT-Secured Authorization Request (JAR)
- RFC 9180 — Hybrid Public Key Encryption (HPKE)
- OpenID Certification program
- Tessaliq MIT-licensed components (SDK, circuits, helpers)