EU wallet credentials (SD-JWT VC)
EU Digital Identity Wallets (eIDAS 2.0) present credentials in two formats: SD-JWT VC and ISO mdoc. DID.is verifies SD-JWT VC through the same endpoint as other credentials, POST /v1/credentials/verify. ISO mdoc is recognised and reported as UNSUPPORTED; it is never reported as valid.
DID.is acts as a verification service. You receive the credential from the wallet yourself, then send it to DID.is to check. DID.is does not talk to wallets and is not a registered EU relying party.
- Issuer signature and key
- Each disclosed claim matches its signed digest
- Dates and revocation status
- Holder key binding, when present
What is checked
Section titled “What is checked”- Structure. The
dc+sd-jwtenvelope, at most 64 disclosures, nesting depth at most 8, 64 KiB in total. - Disclosures. Each disclosed claim must match exactly one SHA-256 digest in the issuer-signed payload, including nested objects and array elements. Duplicate or unreferenced disclosures make the result
INVALID. Decoy digests are allowed. - Issuer signature. EdDSA, ES256 or ES256K, with the issuer key found in one of three ways (see Where the issuer key comes from).
- Dates.
iat,nbfandexp, with no clock-skew allowance. - Revocation. A Token Status List entry (
statuslist+jwt) is fetched and checked. If the list cannot be reached or verified, the result isINDETERMINATE, neverVALID. - Holder key binding. When a Key Binding JWT is attached, it must be signed with the key in
cnf.jwkand cover this exact presentation (sd_hash). If you supplyexpectedNonceandexpectedAudience, they must match.
What is not checked
Section titled “What is not checked”- EU trusted lists. DID.is does not check whether the issuer appears on an EU or Member State trusted list. Every result says so:
issuerTrust.trustedListCheckedis alwaysfalse. - Certificate revocation (CRL or OCSP) for
x5ccertificates. - Credential type metadata (
vctdocuments andvct#integrity). - Legacy formats. The legacy
vc+sd-jwttype and RSA, ES384 or P-384 keys are reported asUNSUPPORTED.
Your data stays out of the answer
Section titled “Your data stays out of the answer”Disclosed values (names, dates of birth, addresses) are never returned. The response lists only the paths of the claims that were disclosed, such as /given_name or /address/locality, and the registered claims iss, vct, iat, nbf, exp and status. The credential field is always null for SD-JWT VC.
Use issuer test credentials when you try the API. Do not send a real person’s credential unless your own data-protection basis covers it.
Request
Section titled “Request”{ "credential": "<issuer-signed JWT>~<disclosure>~…~<optional Key Binding JWT>", "expectedAudience": "optional; checked against the Key Binding JWT aud", "expectedNonce": "optional; checked against the Key Binding JWT nonce", "trustedIssuerCertificates": ["optional; up to 8 PEM or base64 DER certificates you trust"]}expectedNonce is what protects you against a replayed presentation. If you supply expectedNonce or expectedAudience and no Key Binding JWT is attached, the result is INDETERMINATE.
The endpoint uses the intensive budget (10 requests per minute). See Errors and rate limits.
From version 0.2.0 the SDKs accept these options: verifyCredential(credential, { expectedNonce, trustedIssuerCertificates }) in TypeScript and verify_credential(credential, expected_nonce=..., trusted_issuer_certificates=...) in Python. The examples below call the API directly.
# Root certificate as base64 DER (one line)ROOT=$(openssl x509 -in issuer-root.pem -outform DER | base64 -w0)
curl -X POST https://did.is/api/v1/credentials/verify \ -H 'content-type: application/json' \ -d '{ "credential": "'"$SD_JWT_VC"'", "expectedAudience": "https://verifier.example", "expectedNonce": "'"$NONCE"'", "trustedIssuerCertificates": ["'"$ROOT"'"] }'const res = await fetch('https://did.is/api/v1/credentials/verify', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ credential: sdJwtVc, expectedAudience: 'https://verifier.example', expectedNonce: nonce, trustedIssuerCertificates: [issuerRootPem], }),});const v = await res.json();console.log(v.status, v.issuerTrust?.result);import httpx
v = httpx.post( "https://did.is/api/v1/credentials/verify", json={ "credential": sd_jwt_vc, "expectedAudience": "https://verifier.example", "expectedNonce": nonce, "trustedIssuerCertificates": [issuer_root_pem], },).json()print(v["status"], v["issuerTrust"]["result"])Check a condition without receiving the value
Section titled “Check a condition without receiving the value”Often you only need a yes or no: is the holder over 18, does the credential carry a given membership. Send requiredClaims and DID.is answers per condition without returning the claim value.
{ "credential": "<SD-JWT VC>", "expectedNonce": "<your nonce>", "requiredClaims": [{ "path": "/age_over_18", "equals": true }]}"requirements": { "met": true, "results": [{ "path": "/age_over_18", "result": "SATISFIED" }]}pathis a JSON Pointer into the issuer-signed claims after disclosures are applied.equalsis a boolean, an integer or a non-empty string. Up to 16 conditions; anything else is refused with400.- Each result is
SATISFIED,NOT_SATISFIED,NOT_DISCLOSED(the holder did not disclose it, or it is absent) orNOT_EVALUATED. - Conditions are evaluated only when the status is
VALID. An expired, revoked, tampered or unanchored credential givesNOT_EVALUATEDandmet: false, so an unverified value can never satisfy a condition. metistrueonly when the credential isVALIDand every condition isSATISFIED. Decide onmet, not onstatusalone.- The same option works for other credential formats; the path then points into the signed credential, for example
/credentialSubject/ageOver18.
met: true shows that a trusted issuer signed that value. It does not show that the issuer checked it correctly. Add expectedNonce so an old presentation cannot be replayed.
SDK (from 0.2.2): verifyCredential(credential, { requiredClaims: [...] }) in TypeScript and verify_credential(credential, required_claims=[...]) in Python.
Where the issuer key comes from
Section titled “Where the issuer key comes from”DID.is looks for the issuer key in this order. The issuerTrust object in the response states which way was used.
| Source | When it is used | Best possible result | issuerTrust.result |
|---|---|---|---|
| DID | iss is a DID, or kid is a DID URL |
VALID |
NOT_APPLICABLE |
X.509 certificate (x5c) |
The header carries a certificate chain | VALID only if the chain reaches a certificate you supplied in trustedIssuerCertificates; otherwise INDETERMINATE |
CALLER_ANCHORED, NOT_ESTABLISHED or CHAIN_INVALID |
| Issuer metadata | iss is an HTTPS URL that publishes /.well-known/jwt-vc-issuer |
VALID |
NOT_APPLICABLE |
Most EU wallet credentials use an X.509 certificate. A certificate travels inside the credential and anyone can create one, so a matching signature alone does not say who the issuer is. That is why such a credential is INDETERMINATE until you tell DID.is which root certificates you trust.
Reading the result
Section titled “Reading the result”What a VALID result shows
- The issuer-signed part was signed by the issuer key, found through a DID, issuer metadata or a certificate chain that reaches a root you trust.
- Every disclosed claim is one the issuer signed, unchanged.
- The credential is within its dates and not revoked or suspended.
- When key binding is present, the presenter holds the bound key.
What it does not show
- That the issuer is listed or regulated in the EU. No trusted list is checked.
- That the attribute values are accurate.
- That the presentation is fresh, unless you supplied
expectedNonce.
Common results for EU wallet credentials:
| Result | What it means | What to do |
|---|---|---|
VALID, CALLER_ANCHORED |
Signature, disclosures, dates and status passed, and the certificate chain reaches a root you supplied. | Apply your own policy on that issuer. |
INDETERMINATE, NOT_ESTABLISHED |
The signature matches the certificate in the credential, but nothing ties that certificate to an issuer you trust. | Supply the issuer’s root certificate in trustedIssuerCertificates. |
INDETERMINATE (no DNS or URI name) |
The certificate does not name the issuer in iss, so DID.is cannot confirm it belongs to that issuer. |
Check with the issuer how its certificate identifies it. |
INVALID, CHAIN_INVALID |
The certificate has expired, is not yet valid, fails path validation, or names a different issuer. | Reject the credential. The proof check gives the reason and the date. |
INVALID |
A disclosure does not match a signed digest, or the key binding fails. | Reject the credential. |
EXPIRED, REVOKED, SUSPENDED |
The credential is out of date, or its issuer withdrew it. | Reject, or ask the holder for a current credential. |
UNSUPPORTED |
ISO mdoc, the legacy vc+sd-jwt type, or an unsupported key type. |
DID.is draws no conclusion. |
Each check in the result has an id, a status and a detail line: structure, disclosures, issuer, proof, issuer.trust, status and holder.binding.
Try it in the browser
Section titled “Try it in the browser”Paste an SD-JWT VC into did.is/verify. The page shows “SD-JWT detected”, an Issuer trust panel and the list of disclosed claim paths. Under Verifier inputs · optional you can add an expected audience, an expected nonce and trusted issuer certificates.
Public test credentials from the EU reference wallet repositories are useful for trying this, but many of them have expired certificates or certificates that do not name their issuer. Expect INVALID or INDETERMINATE from those, with the reason in the proof check.