SD-JWT VC Format

SD-JWT VC is the credential format Proof uses, defined in RFC 9901 and SD-JWT VC draft 16.

SD-JWT VC is a Verifiable Credential format that encodes a credential as a Selective Disclosure JWT, defined in RFC 9901 and profiled by draft-ietf-oauth-sd-jwt-vc-16. Proof issues every Verifiable Credential in this format.

A presentation of an SD-JWT VC is a single string with three kinds of components, delimited by tildes (~). In an OID4VP response it is wrapped in the vp_token object described in Verify a Credential:

<Issuer-Signed JWT>~<Disclosure 1>~<Disclosure 2>~...~<Key Binding JWT>
ComponentDescription
Issuer-Signed JWTThe base SD-JWT signed by Proof. Carries hashed claim digests in the _sd array, the Holder's public key in the cnf claim, and the credential type in vct.
DisclosuresBase64url-encoded arrays of [salt, claim_name, claim_value]. Only the claims requested by the OID4VP scope value are included.
Key Binding JWTSigned by the Holder's private key in Proof's Cloud Wallet. Carries nonce, aud, and iat. Proves Holder possession, prevents replay, and optionally binds the presentation to specific transaction_data.

Sample payload

The following shows a complete, decoded vp_token for a https://credentials.notarize.com/ProofCredentialV1 presentation where the Verifier requested given_name, family_name, and age_equal_or_over.18. Each component is shown separately.

Issuer-Signed JWT, header

{
  "alg": "ES256",
  "typ": "dc+sd-jwt",
  "x5c": ["<Proof.com end-entity certificate>", "<Proof Organization Authenticity Issuing CA R1>"]
}
📘

The x5c header carries the signing certificate chain, leaf first, without the root. Validate it up to Proof Root CA R1; see Proof Certificate Authority.

Issuer-Signed JWT, payload

{
  "iss": "https://api.proof.com",
  "vct": "https://credentials.notarize.com/ProofCredentialV1",
  "iat": 1739280000,
  "_sd_alg": "sha-256",
  "cnf": {
    "jwk": {
      "kty": "EC",
      "crv": "P-256",
      "x": "b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ",
      "y": "Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8"
    }
  },
  "_sd": [
    "--NrmJe6y2u_S2dC8RKHHv3rElWsqpY8r6xslJuwaJI",
    "JzYjH4svliH0R3PyEMfeZu6Jt69u5qehZo7F7EPYlSE",
    "UgEtkU6-DwPQXQM-UrsaWMd7MOWMB_3iZjDeyp6gvUY",
    "S4sER_VP45OHkmC7-BVsj2NqlA_0jhsJE7P0rTkkUFQ",
    "bViw5OlxKJBrlXkjSBkz2MERPl1gUHnKxHmkR6Zf6Mk",
    "h4JMnGjFOh9HfOB-r09jqeMxEpGELe3BDLn_fuY34vs",
    "qF42T_IHyzGvTIGLH1gPMFGVLW92-Ol9HwFBaFDuGB4",
    "UkVeSoup2-mIUXvkbsm2aQr6BdwyW34Tydo6PtLINQ0"
  ]
}

The cnf.jwk is the public key of the user's end-entity certificate, issued by Proof Individual Authenticity Issuing CA R1. See Proof Certificate Authority for the full chain.

What to validate:

  • iss: must exactly match https://api.proof.com (or the sandbox equivalent). Reject if different.
  • vct: must match the vct_values you specified in your DCQL query.
  • iat: issuance timestamp. Use for audit logging. Optionally reject credentials older than your policy allows.
  • cnf.jwk: the Holder's public key. Use this to verify the Key Binding JWT signature, and nothing else.
  • _sd: array of base64url-encoded SHA-256 digests. Each top-level disclosure must hash to one of these values; a nested disclosure hashes to a digest in its parent's _sd. The array also contains random decoy digests, so its length does not reveal how many claims the credential holds.

Disclosures (4 disclosed)

The basic scope discloses given_name, family_name, and age_equal_or_over.18. Because 18 is nested inside age_equal_or_over, it takes two disclosures: one for the age_equal_or_over object and one for its 18 member. The other claims (birthdate, age_equal_or_over.21, age_equal_or_over.65, and is_national.us when present) are not included.

// given_name (base64url decoded)
["2GLC42sKQveCfGfryNRN9w", "given_name", "Darren"]

// family_name (base64url decoded)
["eluV5Og3gSNII8EYnsxA_A", "family_name", "Louie"]

// age_equal_or_over (base64url decoded)
["Qg_O64zqAxe412a108iroA", "age_equal_or_over", {"_sd": ["<digest>", "<digest>", "<digest>"]}]

// age_equal_or_over.18 (base64url decoded)
["6Ij7tM-a5iVPGboS5tmvVA", "18", true]

Each disclosure is an array of three elements:

  • [0]: salt. A random value that prevents rainbow-table attacks on claim digests.
  • [1]: claim name. The disclosed attribute. For a nested claim this is the member name (18), not the full path. The key you use when extracting claims.
  • [2]: claim value. Type depends on the claim: strings for names, booleans for age threshold claims, objects for structured data.
📘

For each disclosure, compute SHA-256(base64url_encoded_disclosure_string) and verify the resulting digest appears in the _sd array. Hash the raw base64url string as received, not the decoded JSON.

Key Binding JWT

Header:

{
  "alg": "ES256",
  "typ": "kb+jwt"
}

Payload:

{
  "nonce": "n-0S6_WzA2Mj_6a8bRs2TU",
  "aud": "camdrbpxd",
  "iat": 1739280120,
  "sd_hash": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}

What to validate:

  • nonce: must exactly match the nonce you sent in the authorization request.
  • aud: must match your client_id.
  • iat: presentation creation time. Reject if too far in the past (recommended: no more than 5 minutes).
  • sd_hash: SHA-256 hash over the Issuer-Signed JWT concatenated with all disclosures (with ~ delimiters). Binds the KB-JWT to this specific presentation.
  • Signature: verify with cnf.jwk from the Issuer-Signed JWT.

If the request bound transaction_data, the Key Binding JWT carries each value's payload as a top-level claim named after its template (wire_instructions_v1, payment_mandate_v1, payment_itemized_v1, session_data). The full catalog is in Transaction Data Templates.


See also: Proof Certificate Authority · Verify a Credential · Verifiable Credential Types


Did this page help you?