Skip to main content

SD-JWT VC mandate (draft-ietf-oauth-sd-jwt-vc + RFC 7800 + RFC 8707)

Headers and claims

Headers and claims:

  • Header typ=vc+sd-jwt per the draft §5
  • Header alg=EdDSA (default; Ed25519)
  • iss=https://as.oid4pay.com
  • sub=<principal_id>
  • aud=<merchant_resource_url> per RFC 8707
  • iat, nbf, exp
  • vct=https://schema.oid4pay.com/oid4ac/mandate/v1
  • cnf.jkt=<agent_dpop_key_thumbprint> per RFC 7800 + RFC 9449 §6
  • authorization_details (RAR, RFC 9396) carrying capabilities, line_items, offer_digest, step_up_triggers
  • oid4ac_version="1.0"
  • credentialStatus.statusListIndex, credentialStatus.statusListCredential=https://as.oid4pay.com/oauth/status-list/{list_id}

Key-Binding JWT (KB-JWT, draft §4.3), REQUIRED for every presentation: when the agent presents the mandate at a merchant, it MUST attach a KB-JWT signed by the same DPoP key naming the mandate as iat, the merchant's resource URL as aud, a per-presentation nonce, and a fresh iat. Without KB-JWT a leaked mandate is trivially replayable.

Selective disclosure (_sd)

The mandate's _sd array carries hashed claims the holder may selectively reveal at presentation time. Disclosable claims in v1:

The merchant verifier sees only what was disclosed; the Wallet Portal audit log retains the full unredacted record.

Compact form

The presentation is the SD-JWT VC followed by tilde-separated disclosures and the KB-JWT:

<sd-jwt-vc>~<disclosure1>~<disclosure2>~<kb-jwt>

KB-JWT

Header: { "typ": "kb+jwt", "alg": "EdDSA" }
Body: {
  "iat": 1747260400,
  "aud": "https://shop.alpacanica.com",
  "nonce": "sha256(merchant_nonce || offer_digest)",
  "sd_hash": "<b64url SHA-256 of the SD-JWT VC + disclosures>"
}

Verifier rules

  1. Header typ=vc+sd-jwt exactly.
  2. Signature against the AS JWKS.
  3. iss=https://as.oid4pay.com.
  4. aud includes the merchant's resource URL.
  5. exp in the future.
  6. credentialStatus bit not set in the W3C VC Status List.
  7. KB-JWT signature against the key whose thumbprint matches cnf.jkt.
  8. KB-JWT aud matches the merchant origin exactly.
  9. KB-JWT nonce matches the per-presentation merchant-computed value.
  10. KB-JWT iat within 60 s of server clock.

Worked example

import { verifyMandate } from "@oid4pay/oid4ac-merchant";
import { createHash, randomBytes } from "node:crypto";

const merchantNonce = randomBytes(16).toString("base64url");
const expectedNonce = createHash("sha256")
  .update(`${merchantNonce}:${offerDigest}`)
  .digest("base64url");

const m = await verifyMandate(sdJwtVc, kbJwt, asJwks, {
  expectedAudience: "https://shop.alpacanica.com",
  expectedNonce,
});

// m.spendCapMinor, m.currency, m.merchantAllowlist available
// m.disclosed = subset of _sd claims the agent revealed