A field guide to OID4VCI Draft mismatches
If you write an issuer that strictly follows OID4VCI Draft 15, half of the wallets in the wild won't talk to you. If you strictly follow Draft 13, the EU reference wallet won't. The protocol moved fast and shipped breaking changes mid-stride; production wallets are scattered across the resulting versions.
This is a field guide to the differences that actually matter when an issuer wants to interop with everything.
The drafts in question
| Draft | Released | What changed |
|---|---|---|
| 13 | 2024 | Single proof, format+vct body shape. The "old way." |
| 14 | mid-2025 | credential_configuration_id for credential selection; proofs.jwt[] array (batch); dc+sd-jwt format for SD-JWT-VC. |
| 15 | late 2025 | Cleanup of 14; batch_credential_issuance metadata. The current direction. |
| Final 1.0 | TBD | Coming soon — will likely freeze 15. |
The EU reference wallet (eudi-lib-jvm-openid4vci-kt 0.9+) sends Draft 14/15 shape. Older wallets and synthetic-holder test scripts still use Draft 13.
Difference 1 — the credential-request body
Draft 13: flat shape, format-keyed.
{
"format": "vc+sd-jwt",
"vct": "urn:eudi:pid:1",
"proof": { "proof_type": "jwt", "jwt": "<one proof>" }
}
Draft 14/15: configuration-keyed, optional batch.
{
"credential_configuration_id": "urn:eudi:pid:1_sd_jwt_vc",
"proofs": { "jwt": ["<proof1>", "<proof2>", "..."] }
}
If your issuer accepts only one shape, the other shape's wallets get "invalid request" without understanding why.
The fix in @gramota/oid4vci:
import { parseCredentialRequest } from "@gramota/oid4vci";
const parsed = parseCredentialRequest({ body: req.body });
// parsed.proofJwts is always a string[] (length ≥ 1).
// parsed.format and parsed.vct are filled in regardless of which
// shape the wallet sent — Draft 14/15 looks them up from the
// configuration_id when issuerMetadata is provided.
One normalized shape. Consume proofJwts[] and you're done.
Difference 2 — the format string
Draft 13: "format": "vc+sd-jwt"
Draft 15: "format": "dc+sd-jwt"
Yes, one letter changed. vc (verifiable credential) became
dc (digital credential) because the spec authors decided the broader
"dc+" prefix scoped better. Wallets that target Draft 15 metadata
ignore credentials advertised as vc+sd-jwt. Wallets that target
Draft 13 ignore dc+sd-jwt.
There's no graceful fallback at the wallet level — they just don't engage with the offer. Your error budget is "wallet didn't try."
For broadest compatibility, advertise both formats in your
credential_configurations_supported:
{
"credential_configurations_supported": {
"urn:eudi:pid:1_sd_jwt_vc": {
"format": "dc+sd-jwt", // current
// ... rest of config
},
"urn:eudi:pid:1_vc_sd_jwt": { // back-compat alias
"format": "vc+sd-jwt",
// ... same config otherwise
},
}
}
We've found Draft 15 wallets fail closed when the only offered format
is vc+sd-jwt. So leading with dc+sd-jwt and offering vc+sd-jwt
as an alias works in practice.
Difference 3 — the credential response
Draft 13: singular.
{ "format": "vc+sd-jwt", "credential": "<sd-jwt-vc>" }
Draft 15 §7.3: plural.
{
"credentials": [
{ "credential": "<sd-jwt-vc-1>" },
{ "credential": "<sd-jwt-vc-2>" }
]
}
Issue: a Draft 13 wallet that gets a Draft 15 response sees no
top-level credential and silently fails ("Cannot happen" in the EU
wallet's logs is the canonical symptom).
Workaround: emit BOTH shapes in your response.
return reply.send({
format: "dc+sd-jwt",
credential: tokens[0], // Draft 13 fallback
credentials: tokens.map((credential) => ({ credential })), // Draft 15
});
@gramota/issuer doesn't enforce the response shape — that's the
caller's job — but the SaaS we run consistently emits both, and that's
worked across every wallet we've tested.
Difference 4 — batch issuance metadata
Draft 13: no concept of batch.
Draft 14: proofs.jwt[] exists in the wire format.
Draft 15 §11.2.3: batch_credential_issuance published in
/.well-known/openid-credential-issuer.
{
"batch_credential_issuance": { "batch_size": 50 }
}
If you don't advertise this, even Draft 15 wallets fall back to
single-credential mode silently. The EU wallet's metadata parser
sets batchCredentialIssuance=NotSupported when the field is absent
and the user gets one credential per offer instead of the
numberOfCredentials = 10 they'd configured.
This was the most expensive bug in our integration: spent two emulator runs wondering why the wallet was issuing one credential when we knew the route was wired for batch. Symptom-cause distance was infinite. The fix was a four-line metadata change.
Difference 5 — DPoP support discovery
OAuth 2.0 metadata has dpop_signing_alg_values_supported, an array
listing the algorithms the AS accepts in DPoP proofs. Wallets gate
their use of DPoP on this:
{
"issuer": "https://issuer.example",
"token_endpoint": "https://issuer.example/oid4vci/token",
"dpop_signing_alg_values_supported": ["ES256"]
}
Drop this field, the wallet falls back to bearer tokens. That's spec-correct but if you're production-targeting the EU stack, you want DPoP on — production EUDIW wallets all use it. The wallet still works without DPoP, but bearer tokens are weaker (a leak = a clone).
Putting it together
The minimum-viable issuer that talks to every wallet we've tested:
// /.well-known/openid-credential-issuer
{
credential_issuer: ISSUER_URL,
authorization_servers: [ISSUER_URL],
credential_endpoint: `${ISSUER_URL}/oid4vci/credential`,
batch_credential_issuance: { batch_size: 50 }, // Draft 15
credential_configurations_supported: {
"urn:eudi:pid:1_sd_jwt_vc": {
format: "dc+sd-jwt", // Draft 15
vct: "urn:eudi:pid:1",
cryptographic_binding_methods_supported: ["jwk"],
credential_signing_alg_values_supported: ["ES256"],
proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256"] } },
}
}
}
// /.well-known/oauth-authorization-server
{
issuer: ISSUER_URL,
token_endpoint: `${ISSUER_URL}/oid4vci/token`,
grant_types_supported: ["urn:ietf:params:oauth:grant-type:pre-authorized_code"],
token_endpoint_auth_methods_supported: ["none"],
dpop_signing_alg_values_supported: ["ES256"], // wallet gate
}
// POST /oid4vci/credential — accept Draft 13/14/15, return both shapes
import { parseCredentialRequest } from "@gramota/oid4vci";
const parsed = parseCredentialRequest({ body: req.body });
// ... verify proofs, issue ...
return reply.send({
format: "dc+sd-jwt",
credential: tokens[0],
credentials: tokens.map((credential) => ({ credential })),
});
Five fields, two endpoints, one normalisation function. That's the minimum to interop across the OID4VCI version landscape we have today.
What we'd like to see next
Cleanups the spec authors could make our lives easier with:
- Drop
vc+sd-jwtentirely. Two formats for the same credential is one too many. Either pick one and require migration, or document an explicit aliasing rule. - Make
batch_credential_issuanceopt-out instead of opt-in. Most production issuers want batch; making it default-on with a "single only" override would surface fewer silent fallbacks. - Spec a discovery dance for DPoP nonces. Currently the only way
to learn an AS demands a nonce is to try without one and read the
DPoP-Nonceheader on the 401 response. Putting that in metadata would save a round-trip.
Until then, the workaround pattern is "advertise everything, accept
everything, emit both shapes." @gramota/oid4vci handles the
parser side; the metadata + response shaping you put in your route
handler. Five fields, two endpoints, one normaliser.
If you want the working server-side end-to-end, see the
issuer guide. For the wallet side, the
underlying primitives live in @gramota/oid4vci and the high-level
flow runner is Oid4vciClient.