Selective Disclosure JWT (SD-JWT) toolkit for Bun/TypeScript. Implements RFC 9901 concepts with a practical API for issuers, holders, and verifiers, plus optional FHIR helpers. The repo hosts:
core/— the generic SD-JWT implementation.fhir/— FHIR-specific helpers and examples built on the core.public/— the holder-facing demo UI that showcases SD-JWTs in a FHIR context.
bun add sd-jwt-bun
import * as jose from "jose";
import { SDPacker, SDJwt, Verifier } from "sd-jwt-bun";
// Issuer side: pack and sign
const payload = { name: "Alice", email: "alice@example.com" };
const config = { email: true }; // email is selectively disclosable
const packer = new SDPacker();
const { packedPayload, disclosures } = await packer.pack(payload, config);
const { privateKey, publicKey } = await jose.generateKeyPair("ES256");
const jwt = await new jose.SignJWT(packedPayload)
.setProtectedHeader({ alg: "ES256" })
.sign(privateKey);
// Holder sends SD-JWT string (with a trailing "~" when no KB-JWT)
const sdJwt = new SDJwt(jwt, disclosures);
const sdJwtString = sdJwt.toString();
// Verifier side
const parsed = await SDJwt.parse(sdJwtString);
const verifier = new Verifier();
const claims = await verifier.verify(parsed, publicKey, {
// optional KB-JWT policy:
required: false,
nonce: "nonce-here",
aud: "https://verifier.example",
// freshness: defaults to 10 minutes; override with kbMaxAgeSeconds if needed
});
console.log(claims.email); // only present if disclosedTo bind an SD-JWT to a holder key and prevent replay:
- Issuer includes the holder public key in the SD-JWT payload:
{ "cnf": { "jwk": <holder public JWK> }, ... } - Holder computes
sd_hashfor the presentation:const sdHash = await sdJwt.calculateSdHash(); // uses _sd_alg or SHA-256 default
- Holder creates a KB-JWT with header
{ alg: "<holder alg>", typ: "kb+jwt" }and payload:and signs it with the holder private key. Attach it with{ "nonce": "<verifier nonce>", "aud": "<verifier id>", "iat": <epoch seconds>, "sd_hash": "<hash from step 2>" }sdJwt.kbJwt = signedKbJwt. - Verifier enforces binding:
It checks
await verifier.verify(sdJwt, issuerPubKey, { required: true, nonce: "<same nonce>", aud: "<same aud>", kbMaxAgeSeconds: 600, // default; override if your policy differs });
typ, alg/key compatibility,sd_hash,nonce,aud, andiatfreshness. Missing/incorrect values are rejected.
const packer = new SDPacker(saltGenerator?, hashAlg?);
const { packedPayload, disclosures } = await packer.pack(payload, config);payload: any JSON-like value.config: mirrors the payload shape;truemeans conceal; objects/arrays may include_self,_items, and_decoyskeys to hide the container or add decoys.hashAlg: defaults toSHA-256; supports other registry algorithms (e.g.,SHA-512/256,SHA-224,SHA3-256).- Stateless: each call returns its own
disclosuresarray; no retained internal state.
new SDJwt(jwt, disclosures, kbJwt?): hold the issuer-signed JWT, selected disclosures, and optional KB-JWT.toString(includeKbJwt = true): compact SD-JWT or SD-JWT+KB string.calculateSdHash(alg?): computesd_hashfor KB-JWT binding (defaultSHA-256).static parse(str): async parse SD-JWT or SD-JWT+KB string into anSDJwtinstance.
const verifier = new Verifier();
const claims = await verifier.verify(sdJwt, issuerPubKey, {
required?: boolean, // require KB-JWT (default false)
nonce?: string, // required if KB-JWT is present
aud?: string, // required if KB-JWT is present
now?: number, // epoch seconds override for testing
kbMaxAgeSeconds?: number, // default 600s (10 minutes)
kbSkewSeconds?: number, // default 300s (5 minutes)
requireValidityClaims?: boolean, // enforce exp/nbf presence
});- Enforces duplicate digest protections, claim name collisions, correct disclosure shape (array vs object), and default KB-JWT freshness (10 minutes) unless overridden.
normalizeHashAlgorithmmaps common spellings to WebCrypto names.digest(data, alg?)computes base64url digests over US-ASCII bytes of the input; falls back to Nodecryptofor algorithms missing in WebCrypto.
packFhirSdJwt(payload, signingKey, opts?) builds a config from a generated FHIR index and packs/signs a resource. verifyFhirSdJwt(sdJwtString, pubKey) verifies it. See fhir/src/autoSdJwt.ts.
What an SD‑JWT is (in brief):
- The issuer’s JWS payload contains “holes” that are hashes of disclosures.
- A disclosure is a base64url-encoded JSON array: for objects
[salt, key, value]; for arrays[salt, value]. - Arrays use
{ "...": "<digest>" }placeholders; objects list disclosure digests in_sd. - Example object disclosure array:
A matching digest is placed in the issuer payload
["abc123salt", "telecom", [{"system":"phone","value":"555-1234"}]]
_sd. - Example array after packing:
"telecom": [ { "...": "digest-for-index-0" }, { "...": "digest-for-index-1" } ]
What the demo loads:
- Static SD-JWT (
public/data/sdjwt.txt), issuer public JWK, anddisclosures.json. _sd_algis decoded from the JWS; every disclosure is re-hashed with that algorithm.
Reconstruction (holder view):
- Traverse the issuer payload plus disclosures to build:
fullPayload: fully disclosed JSON (root_sd/_sd_algremoved).pathMap: every cutpoint path (e.g.,entry.0.resource.name.1) → its disclosure digest.
- Traversal rules:
- Objects: copy plain props; for
_sdentries that match disclosures, recordpath → digestand recurse into the disclosure value. - Arrays: if an element is
{ "...": "<digest>" }and we have that disclosure, recordpath → digestand recurse into its value.
- Objects: copy plain props; for
Rendering and interaction:
- Every value/container is wrapped in a dashed box with
data-path. - A box is clickable (pointer cursor) if it is a cutpoint or has descendant cutpoints (
pathMapprefix match). - Clicking a box with its own digest toggles that digest; clicking a non-cutpoint toggles all descendant digests.
- Drag/select text: on mouseup, the nearest ancestor whose
data-pathis inpathMapis marked redacted. - Redactions are tracked in a
Setof digests; redacted boxes show strike-through.
Inline examples:
- Patient.name array:
- Issuer payload:
name: [ {"...": "<d0>"}, {"...": "<d1>"} ]. pathMaphasentry.0.resource.name.0 -> d0,entry.0.resource.name.1 -> d1.- Clicking the first name box toggles only
d0; the second remains.
- Issuer payload:
- Condition.code:
- Digest for code lives in
_sd;pathMaphasentry.1.resource.code -> dCode. - Clicking
codetoggles just that digest, not the whole Condition.
- Digest for code lives in
Disclose action:
- Filter out any disclosure whose digest is redacted.
- Rebuild the SD-JWT string (issuer JWS + retained disclosures).
- Verify once with the FHIR-aware verifier (
verifyFhirSdJwt), which validates and strips empty arrays for clean FHIR output. - The demo shows the presentation string, raw JWT payload + encoded disclosures, and the cleaned/verified FHIR JSON.
bun test
Comprehensive unit tests cover packer statelessness, hash algorithm support, verifier correctness, KB-JWT freshness, and FHIR helper paths.