OID4VCI explained
OID4VCI ("OpenID for Verifiable Credential Issuance") is the protocol that gets a credential into a wallet. Don't let the name scare you — it's mostly OAuth 2.0 with three or four extra bytes of vocabulary.
The two flows
OID4VCI defines two ways to start an issuance:
- Pre-authorized code. The issuer mints a one-time code out-of-band (usually printed on a QR), the wallet scans it, no browser login. This is what governments use for "you went to the office, you proved who you are, here's a code to take home and put in your wallet."
- Authorization code. The wallet opens a browser, the user logs into the issuer the normal OAuth way, the issuer hands back a code, the wallet redeems it. Used when the issuer wants the user to log in (banks, employers, online identity providers).
Both flows end at the same place: the wallet has an access_token and
calls the issuer's /credential endpoint to receive a signed
credential.
The wire shape
wallet issuer
────── ──────
1. discover GET /.well-known/openid-credential-issuer →
← metadata
2. discover authz GET /.well-known/oauth-authorization-server →
← token_endpoint, …
3a. (pre-auth) POST /token →
grant_type=…pre-authorized_code
pre-authorized_code=<from QR>
← access_token, c_nonce
3b. (authz code) browser → /authorize →
← code (via redirect)
POST /token (code + PKCE verifier) →
← access_token, c_nonce
4. credential POST /credential →
Authorization: DPoP <access_token>
DPoP: <jwt over htm/htu/jti/iat/ath>
body: { credential_configuration_id,
proofs.jwt: [<proof JWT>, …] }
← { credentials: [{ credential }, …] }
The proof JWT in step 4 is the wallet attesting "this is the public
key I want bound into the credential." The issuer takes the jwk from
the JWT header and sticks it in cnf.jwk of the issued SD-JWT-VC, so
the credential is bound to a key only that wallet holds.
DPoP (RFC 9449)
The Authorization: DPoP header isn't OAuth's normal Bearer.
DPoP "demonstrating proof of possession" makes the access token sender-
constrained: every request includes a fresh JWT signed by a key the
client publishes at the /token step, and the server checks the
JWT's htm (HTTP method), htu (URL), iat, jti (replay-prevention
nonce), and — for protected-resource calls — ath (SHA-256 hash of
the access token).
If an attacker steals the access token, they can't use it without the matching private key. Production EUDIW wallets all use DPoP.
Batch issuance (Draft 14/15)
Newer drafts let the wallet ask for N credentials in one round-trip.
The body sends proofs.jwt: [proof1, proof2, …, proofN] — one proof
JWT per credential, each signed by a different holder key. The issuer
returns credentials: [{credential: …}, …].
The use case: one-time-use credentials. Each presentation reveals a
fresh credential bound to a fresh key, so verifiers can't correlate
two visits to the same store across time. The EU reference wallet
asks for numberOfCredentials = 10 by default.
For server-side support, see @gramota/issuer's issueBatch() in
the batch-issuance guide.
In Gramota
Client side (wallet) — @gramota/oid4vci provides:
Oid4vciClient— high-level orchestrator. Methods:parseOffer,acceptOffer(pre-auth flow),authorize+claim(auth-code flow, two-step around the user's browser hop).requestToken/requestCredential— low-level building blocks if you want to drive the flow yourself.buildDpopJwt,pushAuthorizationRequest,generateCodeVerifier/codeChallenge/generateState— DPoP, PAR, and PKCE helpers for custom transports.
Server side (issuer) — same package:
parseCredentialRequest— accepts both Draft 13 and Draft 14/15 shapes, returns a normalized form your route handler consumes.verifyDpopJwt— validates the wallet's DPoP proof on/tokenand/credential. Returns thejktthumbprint for binding to your issued access token.buildSubdomainIssuerUrl— for multi-tenant issuers using subdomains.
For the credential-minting half of the issuer, see
@gramota/issuer.
Reading the spec
If you have to go to the source: OID4VCI Draft 15. The endpoint shapes in §6–7 and the metadata shapes in §11 are the parts that matter for implementers. The rest is mostly OAuth.