airService.verifyCredential(...) against an SD_JWT_VC verification program and the result drives anything on your side, such as account upgrades, access, or payouts.
AIR checks the presentation before it returns Compliant, but that check runs in the user’s browser. Send the presentation to your backend and verify it there.
What you receive
ACompliant result carries a W3C Verifiable Presentation. For SD_JWT_VC programs it wraps one compact SD-JWT inside an EnvelopedVerifiableCredential:
Non-Compliant, NotFound) carry no presentation.
Holder key binding
Holder binding stops a leaked credential from being presented by someone else.- At issuance, the user’s AIR Account provides a P-256 public key, and the issuer writes it to the credential’s
cnf.jwkclaim. In SDK issuance AIR sends the key to the issuer assigningKey.jwk, and the credential carriescnfwhenever it does; direct issuance has no holder present, so those credentials do not. - At verification, the holder signs a key-binding JWT (KB-JWT) with the matching private key. It covers your
nonce, yourprogramId, the signing time, and a hash of the disclosed claims, so it cannot be reused or edited.
- You passed a
noncetoverifyCredential. - The credential’s issuer JWT contains a holder key in
cnf.jwk(EC P-256).
aud is always the programId you passed.
Without a KB-JWT the token is a bearer presentation: if it leaks through logs, proxies, or a malicious script on the page, someone else can submit it as their own. If that matters for your use case, always send a nonce and require holder binding.
Recommended flow
1
Create a nonce on your backend
Generate at least 128 bits of randomness, for example
crypto.randomBytes(16).toString("base64url"), and store it against the user’s session.2
Pass it to verifyCredential
3
Send the presentation to your backend
On
Compliant, post result.verifiablePresentation to your backend.4
Verify, then consume the nonce
Run the checks below, then delete the nonce so it cannot be used again.
Token anatomy
The compact token is~-separated:
verifiableCredential[0].id and strip the data:application/dc+sd-jwt, prefix. Older results may use data:application/vc+sd-jwt,; accept both.
Issuer JWT. Signed by the credential issuer. The payload carries the always-visible claims, plus _sd digests for the selectively disclosable ones:
cnf is present only on holder-bound credentials, and status only when the issuer publishes a token status list.
Disclosures. Each is base64url-encoded JSON such as ["<salt>", "given_name", "Ada"]. A disclosure is valid only if its SHA-256 digest appears in an _sd array of the signed payload. The token contains only the disclosures for the fields you requested; the others stay hidden behind their digests.
KB-JWT. Signed by the holder’s key from cnf.jwk:
sd_hash is the base64url SHA-256 of everything before the KB-JWT, including the trailing ~. It locks the set of disclosures, so nobody can add or remove one after the holder signed.
Checks
Always
- Issuer is trusted.
issis on your allowlist. Choose the issuer’s key source from your own configuration, never from a URL taken from the token. - Issuer signature is valid, using the issuer’s keys:
did:webissuer: fetch the DID document.did:web:issuer.exampleresolves tohttps://issuer.example/.well-known/did.json, anddid:web:issuer.example:pathtohttps://issuer.example/path/did.json. Use thepublicKeyJwkof itsassertionMethodentries. The JWT headerkidis the full DID URL (did:web:issuer.example#key-1); older credentials carry only the fragment (key-1).- HTTPS issuer: use the issuer’s JWKS URL, the one registered with AIR for the issuing partner. If you are the issuer, that is your own JWKS URL.
- Disclosures are genuine.
_sd_algissha-256, and every disclosure’s digest appears in the signed payload. - Credential is in date.
exphas not passed, andnbf(if present) has. - Credential type is right.
vctequals the type you expect. - You got what you asked for. Every field in your
fieldsToDiscloseis present among the verified claims. - Optional: not revoked. See Revocation.
Additionally, when a KB-JWT is expected
Expect a KB-JWT whenever you sent a nonce and the issuer JWT hascnf.jwk.
- KB-JWT is present. If it is missing, reject: someone stripped the binding.
- Header is
typ: kb+jwtwithalg: ES256. - Signature verifies against the issuer JWT’s
cnf.jwk. nonceequals the nonce you issued for this session. Consume it after one check.audequals yourprogramId.iatis recent: no older than your freshness window (for example 5 minutes), and not in the future beyond a small clock-skew allowance (for example 60 seconds).sd_hashequals the base64url SHA-256 of the token up to and including the~before the KB-JWT.
Decision table
A KB-JWT whose
nonce, aud, signature, or sd_hash does not match is always a rejection.
Reference implementation (TypeScript, Node.js)
Built on@sd-jwt/sd-jwt-vc and jose, the libraries AIR itself uses. Tested with @sd-jwt/sd-jwt-vc 0.20 and jose 5 on Node.js 22, covering the valid cases and the rejections in the decision table: stripped KB-JWT, wrong aud, wrong nonce, stale KB-JWT, a disclosure removed after signing, and a forged issuer signature.
- The library checks the KB-JWT’s
typ, signature,nonce, andsd_hash, but notaudor how oldiatis. The function checks those itself. - The library ignores the KB-JWT entirely unless you pass
keyBindingNonce. The function passes it whenever a KB-JWT is expected, so a stripped KB-JWT fails withKey Binding JWT not exist.
verify-air-sd-jwt.ts
claims holds every verified claim: the always-visible ones (iss, vct, cnf, and so on) plus the disclosed fields, with nested fields as nested objects (claims.address.city). Undisclosed fields are absent. holderBound tells you whether a KB-JWT was verified.
Any failure throws, so treat an exception as “not verified”.
Revocation
Credentials whose issuer publishes an IETF Token Status List carrystatus.status_list.{uri, idx} in the issuer JWT. To check it, pass checkStatus: true. The library then:
- Fetches
uriwithAccept: application/statuslist+jwt. The response must use that content type. - Verifies the status-list JWT with the same issuer keys.
- Rejects the credential if the entry at
idxis not0(valid).
verifyCredential only when the verification program enables issuer revocation checks. If you store presentations and re-verify them later, check the status at that point. Issuers without a status list answer per credential at GET /revocation-status/:nonce; see Revoke credentials.
Troubleshooting
References
- Selective Disclosure for JWTs (SD-JWT): token format, disclosures, KB-JWT,
sd_hash. - SD-JWT-based Verifiable Credentials (SD-JWT VC):
vct,cnf,status,dc+sd-jwtmedia type. - Token Status List: revocation.
- VC Data Model 2.0: Enveloped Verifiable Credentials: the
data:URL wrapper. - did:web method: resolving
did:webissuers.