Skip to main content
Use this guide when you call 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

A Compliant result carries a W3C Verifiable Presentation. For SD_JWT_VC programs it wraps one compact SD-JWT inside an EnvelopedVerifiableCredential:
The presentation wrapper, including holder, is not signed. Only the compact SD-JWT inside id is. Everything you trust must come from that token.
Other statuses (i.e. 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.jwk claim. In SDK issuance AIR sends the key to the issuer as signingKey.jwk, and the credential carries cnf whenever 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, your programId, the signing time, and a hash of the disclosed claims, so it cannot be reused or edited.
AIR attaches a KB-JWT only when both of these are true:
  1. You passed a nonce to verifyCredential.
  2. The credential’s issuer JWT contains a holder key in cnf.jwk (EC P-256).
The KB-JWT’s 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.
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:
To get it, take 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

  1. Issuer is trusted. iss is on your allowlist. Choose the issuer’s key source from your own configuration, never from a URL taken from the token.
  2. Issuer signature is valid, using the issuer’s keys:
    • did:web issuer: fetch the DID document. did:web:issuer.example resolves to https://issuer.example/.well-known/did.json, and did:web:issuer.example:path to https://issuer.example/path/did.json. Use the publicKeyJwk of its assertionMethod entries. The JWT header kid is 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.
  3. Disclosures are genuine. _sd_alg is sha-256, and every disclosure’s digest appears in the signed payload.
  4. Credential is in date. exp has not passed, and nbf (if present) has.
  5. Credential type is right. vct equals the type you expect.
  6. You got what you asked for. Every field in your fieldsToDisclose is present among the verified claims.
  7. 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 has cnf.jwk.
  1. KB-JWT is present. If it is missing, reject: someone stripped the binding.
  2. Header is typ: kb+jwt with alg: ES256.
  3. Signature verifies against the issuer JWT’s cnf.jwk.
  4. nonce equals the nonce you issued for this session. Consume it after one check.
  5. aud equals your programId.
  6. iat is 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).
  7. sd_hash equals 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 code works around two library behaviors:
  • The library checks the KB-JWT’s typ, signature, nonce, and sd_hash, but not aud or how old iat is. 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 with Key Binding JWT not exist.
verify-air-sd-jwt.ts
Usage on your backend:
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 carry status.status_list.{uri, idx} in the issuer JWT. To check it, pass checkStatus: true. The library then:
  1. Fetches uri with Accept: application/statuslist+jwt. The response must use that content type.
  2. Verifies the status-list JWT with the same issuer keys.
  3. Rejects the credential if the entry at idx is not 0 (valid).
AIR performs this check during 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