Building a TypeScript SDK for the EU Digital Identity Wallet
If you’re a regulated business that has to accept EU Digital Identity Wallet credentials by December 2027, the situation you’re walking into is messy. Here’s what I’ve learned building one of the few TypeScript-native SDKs for it — what’s hard, what I shipped, and what I’d still do differently.
The European Digital Identity Wallet has a deadline. By December 2026, every EU member state must offer one. By December 2027, banks, telcos, healthcare, fintech, mobility, and gatekeeper platforms have to accept it. That’s a hard regulatory clock — Regulation (EU) 2024/1183— for what is, today, still a moving target.
Most production-grade tooling is JVM- or Rust-rooted. The TypeScript ecosystem has solid lower-level primitives but few opinionated, EUDIW-shaped SDKs that ship with batteries — wire-format validation, x5c chain validation, DPoP, JAR, IETF Token Status List revocation, and end-to-end tests against the EU reference wallet — in a form a Node/TypeScript backend team can adopt in an afternoon.
So I built one. Gramotais an Apache-2.0 TypeScript SDK plus a hosted SaaS for verifiers and issuers. 15 published npm packages with Sigstore provenance, around 580 mock plus 31 live conformance tests, and end-to-end roundtrips against the EU Commission’s reference wallet on Android with our dev verifier cert added to its bundled trust list. This is what’s inside it, what was hard, and what I’d do differently.
Why this needs to exist
ENISA noted in early 2026 that “no EUDI Wallet has been deployed or certified, and the specification remains work in progress.” Of 1,260 conformance tests run at the 2025 EUDIW Launchpad, peer-to-peer interop succeeded at 74%, but tests against the EU Reference Implementation succeeded at only 44%. Member-state cohorts split four ways: a few have public sandboxes (Denmark, France, Germany, Ireland), most have repos but no sandbox, and the long tail is “uncertain.”
So buyers are stuck. They have a 2027 acceptance deadline, a spec that’s still being clarified, reference code maintained for conformance rather than developer experience, and a half-dozen open-source SDKs that each cover part of the surface but never the whole loop end-to-end with adversarial test coverage. If you’re a fintech that needs to accept an EUDIW credential next year for KYC, you can’t wait for the ecosystem to settle.
A relying party stack needs to handle:
- The OAuth 2.0 layer underneath — PKCE, Pushed Authorization Requests (PAR, RFC 9126), DPoP (Demonstrating Proof of Possession, RFC 9449)
- OpenID for Verifiable Credential Issuance (OID4VCI) 1.0, where wallets in the wild straddle Draft 13 and Draft 14/15 wire shapes
- OpenID for Verifiable Presentations (OID4VP) 1.0, with Digital Credentials Query Language (DCQL) for query construction
- SD-JWT VC for the credential format, with selective disclosure and KB-JWT (Key Binding JWT)
- Per-organisation X.509 certificates for signed Authorization Requests (JAR — JWT-Secured Authorization Requests, RFC 9101)
- IETF Token Status List for revocation
- Trust chain resolution against issuer metadata
- Cross-format support (SD-JWT VC today, ISO 18013-5 mdoc tomorrow for mobile driving licence)
- Test coverage against the actual EU reference wallet, not just spec fixtures
That list is most of why “should be a weekend project” turns into a year-and-a-half of work.
What I built
Five repositories.
eudi-gateway— the open-source SDK monorepo. 15 published@gramota/*packages plus 2 internal. Apache 2.0. pnpm + Turborepo + vitest.gramota-saas— the hosted multi-tenant offering atapp.gramota.eu. Fastify 5 API + Angular 21 dashboard. Imports the SDK as npm deps.gramota-identity—auth.gramota.eu. ASP.NET Core 10 + Duende IdentityServer + Angular 21. Custom multi-tenancy, super-admin impersonation with audit logs, PAT auth alongside cookies and JWTs.gramota-demo-store— Solnce, a fictional storefront. React + Vite. Live mode hits the API, mock mode runs offline. Drives the marketing demo.gramota-site—gramota.eu. Analog.js with build-time SSG. Pulls API docs from each@gramota/*package’s TypeDoc output.
The OSS SDK is the centerpiece. The public surface is namespaced as client.<resource>.<verb> — verifier.presentations.verify(), issuer.credentials.issueBatch(), holder.credentials.*, holder.offers.*. Six plug-in slots sit behind it: AuthorizationTransport (PAR / Direct / JAR), Signer (default JwkSigner, swap for HSM/KMS/WebAuthn), TrustResolver, StatusResolver, CredentialStore, plus a Registry for CredentialFormatHandlerso new formats (mdoc next) plug in without changing core. MANIFEST principle 4 sets “Stripe-grade DX” as the explicit target.
The 15 packages: @gramota/sdk (top-level facade), verifier, issuer, holder (the three domains), oid4vp and oid4vci (the wire protocols), dcql and presentation-exchange(the two query languages — DCQL is the new one, PE the legacy), qr (deep-link rendering), jose (JWS / x5c / JWK), sd-jwt (the credential format with KB-JWT), credential-format (registry for new formats), trust (issuer trust resolution), status-list (IETF revocation), and core (shared primitives).
The hardest engineering parts
1. The 12-check verifier pipeline
Every inbound vp_token goes through twelve checks: structure parse → trust resolution → issuer signature → hash binding (with disclosure forgery detection) → KB-JWT presence → cnf-binding → KB signature → audience → nonce → time → transcript → optional status check. Each one records into a SecurityCheck[] audit trail that ships back with the verification response.
The subtle bit is rule classification. verifyKeyBinding throws a singleError. The verifier’s classifyKbFailurehelper maps that one throw to one of seven specific check names by error message regex — so the audit trail says “KB-JWT signature failed” or “KB nonce mismatch” instead of “key binding error.” That’s the difference between a debuggable system and one that makes auditors squint.
The whole thing exposes a Stripe-shaped surface (verifier.presentations.verify, verifier.requests.create, verifier.responses.verify) but every path funnels through the same core method. The audit shape is identical regardless of which entry point you used.
2. OID4VCI Draft normalization with batch + DPoP
The EU reference wallet today sends OID4VCI Draft 14/15 (proofs.jwt[], credential_configuration_id). My own synthetic holder sends Draft 13 (proof.jwt, format, vct). Both shapes are valid in production — Drafts 14 and 15 share a wire shape with one extra field, so the normalizer is two branches, not three. It collapses both into a single ParsedCredentialRequest so downstream code never sees the draft version.
DPoP enforcement (RFC 9449) sits next door, and it’s surprisingly easy to get wrong. The verifier checks htm and htu (with query and fragment stripped per §4.2), enforces an iat skew window, replays via injectable hasSeenJti/recordJti (so you can swap in Redis), optionally verifies ath = base64url(sha256(token)) for token-bound proofs, optionally accepts a server-provided nonce, and returns the jkt thumbprint for token binding. The crucial detail: recordJti only fires afterevery other check passes — otherwise a malformed or unsigned proof can poison the replay store.
Token binding closes the loop. The token endpoint captures jkt at /token, then the credential endpoint re-verifies at /credential and rejects if verified.jkt !== offer.dpopJkt. That’s the difference between “DPoP supported” and “DPoP enforced.”
There’s also a postWithDpopRetry helper that handles the server-issued use_dpop_nonceretry dance transparently — first request gets a 401 with a server nonce, you re-sign with the nonce included, second request succeeds. The retry helper sits in @gramota/oid4vci; the holder, issuer, and verifier all import it, so the nonce dance lives in exactly one place.
3. Per-org X.509 certificates and signed JAR
OID4VP authorization requests come in three flavours: unsigned (the bare URL), a signed JWS, or a JWS signed by a self-signed leaf cert with the cert embedded in the x5c header — the x509_san_dns client identifier scheme (called client_id_scheme in OID4VP Final 1.0, renamed to client_id_prefix in 2.0). The EU reference wallet only accepts the third. So @gramota/oid4vp generates an ES256 keypair plus a self-signed leaf via @peculiar/x509 with: SAN-DNS for the verifier hostname (plus extras for wildcard tenants), serverAuth + clientAuth Extended Key Usage flags, digitalSignature + keyEncipherment Key Usage, BasicConstraints CA:false, and a 20-byte serial. Then the JAR signer wraps the request as a compact JWS with typ: oauth-authz-req+jwt and embeds the cert in the x5c header.
The reference wallet’s request authenticator is strict, based on what it accepts in the field. It rejects unsigned request_uri outright. It insists URL client_id byte-equals JWT client_id. It reads the leaf cert’s SAN-DNS and refuses to proceed if the request’s host doesn’t match. So the SaaS persists the cert to disk between dev restarts (so the wallet’s bundled trust list keeps trusting it) and embeds wildcard *.<base> SAN so one cert covers every per-tenant subdomain.
4. DCQL matching against SD-JWT-VC disclosures
DCQL (Digital Credentials Query Language) is a JSON query language baked into OID4VP 1.0 that lets a verifier ask for credentials by format, claim, and combination — all in one document. The matcher traverses a query path that may target either a selectively-disclosable claim (single segment, look up by name in the parsed disclosures) or a directly-included claim (multi-segment JSONPath-ish), enforces optional meta.vct_values and claim.values constraints, and returns the minimal disclose: string[]set — so the wallet sends only what was asked for, not the whole credential.
Format-handles both vc+sd-jwt (legacy) and dc+sd-jwt (the current IETF SD-JWT VC token type, what the EU verifier and OID4VP 2.0 emit today). Used by both the verifier (against an inbound vp_token) and the holder (to plan a presentation).
5. KB-JWT 9-rule verifier
Key binding is the part of SD-JWT VC that prevents a stolen credential from being replayed by anyone but the legitimate holder. @gramota/sd-jwt implements all nine rules from the IETF SD-JWT VC spec.
Rule 9 (transcript) is the subtle one. The KB-JWT’s sd_hash must equal the verifier’s own computeSdHash(parsed.presentationPrefix, hashAlg) over <issuer-jws>~<d1>~...~<dN>~, where hashAlg is read from the parent SD-JWT’s _sd_alg claim and defaults to sha-256. Mismatch means a disclosure was added, removed, or reordered in transit — adversarial behavior, fail closed.
Rule 6 (audience) accepts an array because production EU wallets put either the verifier’s audience URL (per the SD-JWT-VC spec) or the OID4VP client_id (e.g. x509_san_dns:verifier.example) into aud. Both are valid in the field. The verifier accepts either.
Engineering choices worth calling out
Surface consistency over feature creep
Earlier versions of @gramota/verifier exposed both flat methods (verify, response, request) and namespaced ones. In 0.5.0 the flat methods were removed outright. @gramota/issuer still exposes both shapes (both call into the same impl) so callers can migrate at their pace. The principle: one canonical name per operation, deprecate or delete the rest, accept the breaking change cost.
Multi-tenant via subdomain in the SaaS
Each org gets <slug>.gramota.eu. The slug is validated as an RFC 1035 DNS label. Well-known endpoints mount at the standard RFC 8414 paths (no path prefix), so the metadata fetch URL byte-equals the issclaim — a requirement that’s easy to violate when you “namespace” issuers behind path prefixes.
Tenant resolver as a Chain of Responsibility across three Strategies
In the identity service: HostBasedTenantResolver → ClaimBasedTenantResolver → HeaderBasedTenantResolver, run in priority order, first non-null wins. The host-based one peels the leftmost DNS label off the Host header, validates against an apexDomain (gramota.eu) plus a reservedHosts list of full hostnames (auth.gramota.eu, app.gramota.eu, api.gramota.eu, etc.), and looks up by Tenant.Slug. The combination of an apex-suffix check (so evil.comcan’t claim a tenant) and a no-multi-label check (so evil.com.gramota.eu doesn’t get sliced into a label evil.com) prevents host-spoofing tenant hijack.
One consolidated EF DbContext
The identity service hosts ASP.NET Identity + Duende IdentityServer’s IConfigurationDbContext + IPersistedGrantDbContext+ Gramota’s domain (Tenants, Memberships, PATs, ImpersonationLogs, LogEntries, Invitations) in a single AppDbContext. The comment justifies the deviation from the conventional 4-context layout (ASP.NET Identity + Duende’s two contexts + your domain): atomic signup, FK enforcement across user↔membership, single migration history, single connection. Partial classes split the file by concern.
Two parallel auth schemes that produce the same req.organization
User JWT prefix eyJ* (issued by gramota-identity for dashboard sessions and SDK calls) → JWT path, verified against IdentityServer JWKS with tenant_id claim resolving the org. Integrator API key prefix gk_* → API key path, SHA-256 hash lookup with timingSafeEqual. Route handlers don’t know which scheme authenticated — req.organization is identical either way. Recently extended to multi-issuer JWT trust, so you can hang multiple identity servers off the same SaaS.
Two-tier test convention
Tier 1: ~580 mock + conformance tests, ~2 seconds, no network, gated on every push. Tier 2: 31 live tests under packages/e2e/tests/interop/, gated by EUDI_LIVE=1, run nightly and pre-release. Live tests hit dev.{issuer-backend, authenticate, verifier-backend}.eudiw.dev and issuer.eudiw.dev. The MANIFEST principle reads: “we’ve already caught the DCQL migration, the dc+sd-jwtformat switch, and the PAR-required-per-client policy this way.”
One specific test is worth calling out: it issues a synthetic PID-shaped SD-JWT VC and submits it against the live EU verifier’s DCQL query. The synthetic credential won’t verify against an EU trust anchor — that’s not the point. The point is to prove that our DCQL matcher accepts the same shapes the EU verifier accepts, independent of trust. Our query engine is independently verified against the EU’s own.If a credential we construct passes our matcher and the EU’s, we’re structurally aligned.
What I’d do differently
The OID4VCI draft churn.Building a normalizer for Drafts 13 / 14 / 15 was the right move, but I’d start with the canonical Draft 15 shape internally and treat older drafts as adapters rather than first-class branches. Same outcome, less branching in hot paths.
Cert lifecycle.The current SaaS persists the dev cert to disk so wallet restart doesn’t break trust. For production, this needs to graduate to a proper key vault (HSM / KMS / Vault) with rotation. The Strategy pattern is in place — Signeris pluggable — but the production-ready signer implementation is the next chunk of work.
Observability. The DB-backed ILogger with bounded Channel and drainer HostedServiceworks, indexes are in place. But for a SaaS at scale I’d want OpenTelemetry traces flowing into something like Honeycomb or Tempo, not just structured logs. That’s day-2.
Single-format SDK. Today the SDK handles SD-JWT VC. ISO mdoc (mDL) is the other major credential format and is mandatory for some EUDIW use cases (mobile driving licence, anything ICAO-aligned). The CredentialFormatHandler registry is built for this — adding mdoc is a plug-in not a fork — but it’s still a chunk of work and the second-best thing to do after shipping the production cert lifecycle.
What’s live
- 15 npm packages published with Sigstore provenance attestations
- ~580 mock + conformance tests + 31 live tests against EU reference infrastructure
- End-to-end roundtrip against the EU reference Android wallet, with our dev verifier cert added to its bundled trust list (issue → store → present → verify)
- Hosted SaaS at
app.gramota.eu, identity atauth.gramota.eu, marketing + docs atgramota.eu - Demo store— Solnce, fictional storefront with age, residency, and identity verification
- All source code on GitHub under
gramota-org, Apache 2.0
What this is not
- Not a wallet. Gramota is verifier-side and issuer-side. If you need to ship a wallet, you want the EU reference apps as a starting point, not this.
- Not a Trust Service Provider.Gramota doesn’t notarize, doesn’t run a trust anchor, doesn’t qualify for the EU Trusted List.
- Not certified by any conformance scheme yet. No certification scheme exists yet. When one does, certifying against it is on the roadmap.
- Not yet running in production for a paying buyer.The SaaS exists, the SDK is published, the EU reference wallet roundtrip works, but the first paid integration is still ahead. If that’s a dealbreaker for you, hire a vendor with SLA and indemnity. If you can pilot, this is the deepest TypeScript-native EUDIW expertise you’ll find from one person.
- Not multi-format yet. SD-JWT VC today, ISO mdoc on the roadmap. The
CredentialFormatHandlerregistry is built for this — mdoc is a plug-in, not a fork — but it’s still a chunk of work. - Not a one-person dependency forever.Apache 2.0, full source on GitHub, every architectural decision documented in MANIFEST.md and inline. If I’m unavailable, your team can take over without losing a week.
If you’re scoping this for 2027
I take on a small number of EUDIW integrations per year, typically 6–10 weeks each, embedded with one or two of your senior engineers so the codebase transfers. A typical engagement looks like:
- Weeks 1–2— audit your existing identity stack against EUDIW requirements, written architecture document, scope of integration work
- Weeks 3–5— spike one credential type end-to-end (PID for KYC is the most common), live against EU reference infra
- Weeks 6–8— production hardening: HSM/KMS-backed signer, observability, threat model review, runbooks
- Week 9+— handoff with documented playbooks; optional retainer for follow-up questions
If that shape fits what you’re scoping, here’s how to start the conversation.
Scoping a 2027 EUDIW integration?
60-min technical call — no slides, no pitch. Answers your questions about how the integration would actually work for your stack.