Packages · Digital identity & credentials
k2gl/sd-jwt
Selective Disclosure for JWTs (RFC 9901): issue, present, verify.
Issue and verify SD-JWTs — the credential format behind OpenID4VC and the EU Digital Identity Wallet.
Install
composer require k2gl/sd-jwt Reach for it when
- You issue credentials where the holder decides which claims to reveal (SD-JWT, RFC 9901).
- You verify SD-JWT presentations, with or without Key Binding, e.g. as an EUDI relying party.
Look elsewhere when
- You want plain JWTs with all claims visible — any JWT library covers that.
k2gl/sd-jwt
Selective Disclosure for JWTs (RFC 9901) in pure PHP: issue SD-JWTs with selectively disclosable claims, build Holder presentations with optional Key Binding, and verify both. This is the credential format at the core of OpenID4VC/HAIP and the EU Digital Identity Wallet.
The test suite is driven by the worked examples embedded in RFC 9901 itself (Sections 4.2 and 5, Appendix A.5 key), verified byte for byte.
Fail-closed by design: every MUST-level rule of the RFC's verification algorithm — duplicate
digests, unreferenced Disclosures, reserved claim names, claim collisions, alg: none,
Key Binding mismatches — throws; nothing is silently accepted.
Install
composer require k2gl/sd-jwt
Requires PHP 8.1+. Crypto is provided by k2gl/dsse signers
and verifiers over ext-openssl (ES256/ES384/ES512, RS256) and ext-sodium (EdDSA).
Usage
Issue an SD-JWT
Wrap everything selectively disclosable in Sd::hide() — object properties, array
elements, or whole subtrees (nesting produces recursive Disclosures per Section 4.2.6).
Sd::decoy() inserts decoy digests.
use K2gl\SdJwt\Jws\JwsSigner;
use K2gl\SdJwt\Sd;
use K2gl\SdJwt\SdJwtIssuer;
$issuer = new SdJwtIssuer(JwsSigner::es256FromPem($issuerPrivatePem));
$sdJwt = $issuer->issue(
claims: [
'iss' => 'https://issuer.example.com',
'iat' => time(),
'sub' => 'user_42',
'cnf' => ['jwk' => $holderPublicJwk], // enables Key Binding
'given_name' => Sd::hide('John'),
'address' => Sd::hide([
'street_address' => Sd::hide('123 Main St'), // recursive
'country' => 'US',
]),
'nationalities' => [Sd::hide('US'), Sd::hide('DE'), Sd::decoy()],
],
header: ['typ' => 'example+sd-jwt'],
);
$compact = $sdJwt->toCompact(); // <JWT>~<Disclosure>~...~
Present selected claims (Holder)
Selection uses JSON Pointers into the fully disclosed payload; parent Disclosures that a nested claim depends on are included automatically.
use K2gl\SdJwt\Presentation;
$presentation = Presentation::of($compact);
$presentation->disclosablePaths(); // ['/given_name', '/address', '/address/street_address', ...]
// Without Key Binding:
$toVerifier = $presentation->disclose('/given_name', '/nationalities/0')->toCompact();
// With Key Binding (RFC 9901 Section 4.3):
$toVerifier = $presentation
->disclose('/given_name')
->withKeyBinding(
JwsSigner::es256FromPem($holderPrivatePem),
audience: 'https://verifier.example.org',
nonce: $nonceFromVerifier,
);
Verify a presentation (Verifier)
use K2gl\Dsse\PublicKey;
use K2gl\SdJwt\Exception\SdJwtException;
use K2gl\SdJwt\KeyBinding;
use K2gl\SdJwt\SdJwtVerifier;
$verifier = new SdJwtVerifier;
try {
$verified = $verifier->verifyPresentation(
$toVerifier,
PublicKey::fromJwk($issuerPublicJwk), // or PublicKey::fromPem(...)
KeyBinding::required(
audience: 'https://verifier.example.org',
nonce: $nonceFromVerifier,
),
);
$claims = $verified->claims(); // the Processed SD-JWT Payload, digests resolved
} catch (SdJwtException $e) {
// not verified — bad signature, tampered Disclosure, replayed or missing Key Binding, ...
}
Use KeyBinding::notRequired() for presentations without Holder binding, and
$verifier->verify($sdJwt, $issuerKey) on the Holder side after receiving an SD-JWT from
an Issuer.
The verified result also says which claims came through Disclosures and which array elements were withheld, as JSON Pointers with array positions as issued — the coordinates SD-JWT VC Type Metadata uses to say what may or must be selectively disclosed:
$verified->claims()['nationalities']; // ['US', 'FR'] — the middle one was not disclosed
$verified->disclosedPaths(); // ['/nationalities/0', '/nationalities/2', '/address/street']
$verified->undisclosedPaths(); // ['/nationalities/1'] — a decoy digest looks the same
JWS JSON serialization
RFC 9901 Section 8 defines an alternative to the ~-separated compact form: a JWS JSON
object (RFC 7515 Section 7.2) whose unprotected header carries the Disclosures and, for a
presentation, the Key Binding JWT. Everything above accepts it wherever a compact string is
accepted — SdJwt::parse() tells the two apart by the leading { — and every SD-JWT can be
emitted in it:
$json = SdJwt::parse($compact)->toJson(); // Flattened (Section 8.2)
$json = SdJwt::parse($compact)->toJson(general: true); // General (Section 8.3)
$json = $presentation->disclose('/given_name')->toJson();
$verified = $verifier->verifyPresentation($json, $issuerKey, KeyBinding::required(...));
A General serialization may carry several signatures; the Disclosures and kb_jwt belong to
the first one, and that is the signature verified. The others are kept so that toJson()
re-emits the document unchanged — toCompact(), which has no room for them, drops them.
The sd_hash of a Key Binding JWT is always computed over the compact form rebuilt from the
JSON parts, as Section 8.1 requires.
Scope
- Compact serialization (
<JWT>~<Disclosure>~...~[<KB-JWT>]) and the JWS JSON serialization (Section 8, Flattened and General) — issue, present, verify. - Object-property, array-element, and recursive Disclosures; decoy digests.
- Key Binding JWTs: creation and full Section 7.3 validation (
typ, algorithm-to-cnfmatch,iatwindow,aud,nonce,sd_hash). _sd_alg: sha-256 (default), sha-384, sha-512.- JOSE algorithms: ES256, ES384, ES512, EdDSA, RS256 (signing); verification takes any
k2gl/dsse
Verifier.
Design
- The encoded Disclosure is authoritative: digests are computed over the base64url string exactly as received, never over re-serialized JSON, so foreign encodings survive verification untouched.
- Processing keeps the JSON object/array distinction (
stdClassvs list) end to end;->payload()returns the lossless view,->claims()the convenient array one. - The verifier is policy-explicit: allowed JOSE algorithms, allowed hash algorithms, clock, and leeway are constructor parameters with safe defaults; Key Binding is decided by the Verifier's policy, never by what the Holder sent.
License
MIT © Nick Harin
API
Public classes and methods, generated from the source.
K2gl\SdJwt\Disclosure class
fromEncoded(string $encoded): selfforProperty(string $salt, string $claimName, mixed $value): selfforArrayElement(string $salt, mixed $value): selfisArrayElement(): booldigest(string $hashAlgorithm = HashAlgorithm::DEFAULT): string
K2gl\SdJwt\Exception\InvalidSdJwtException class
K2gl\SdJwt\Exception\KeyBindingVerificationFailed class
K2gl\SdJwt\Exception\SdJwtException class
K2gl\SdJwt\Exception\SelectionException class
K2gl\SdJwt\Exception\SignatureVerificationFailed class
K2gl\SdJwt\Jws\JwsSigner class
es256FromPem(string $pem): selfes384FromPem(string $pem): selfes512FromPem(string $pem): selfed25519(string $secretKey): selfrs256FromPem(string $pem): selfwithAlgorithm(Signer $signer, string $algorithm): selfalgorithm(): stringsign(array|stdClass $payload, array $header = []): string
K2gl\SdJwt\KeyBinding class
required(string $audience, string $nonce, int $maxAgeSeconds = 600): selfnotRequired(): self
K2gl\SdJwt\Presentation class
of(SdJwt|string $sdJwt): selfpayload(): stdClassclaims(): arraydisclosablePaths(): arraydisclose(string ...$paths): selfdiscloseAll(): selftoSdJwt(): SdJwttoCompact(): stringtoJson(): stringwithKeyBinding( JwsSigner $signer, string $audience, string $nonce, ?int $issuedAt = null, ): string
K2gl\SdJwt\Sd class
hide(mixed $value): selfdecoy(): self
K2gl\SdJwt\SdJwt class
parse(string $serialized): selffromJson(string|array $json): selfcreate(string $issuerSignedJwt, array $disclosures, ?string $keyBindingJwt = null): selfhasKeyBinding(): boolwithoutKeyBinding(): selfheader(): stdClasspayload(): stdClasstoCompact(): stringtoJson(?bool $general = null): stringtoJsonArray(?bool $general = null): array
K2gl\SdJwt\SdJwtIssuer class
__construct( private readonly JwsSigner $signer, private readonly string $hashAlgorithm = HashAlgorithm::DEFAULT, ?Closure $saltGenerator = null, )issue(array|stdClass $claims, array $header = []): SdJwt
K2gl\SdJwt\SdJwtVerifier class
__construct( private readonly array $allowedAlgorithms = self::DEFAULT_ALGORITHMS, private readonly array $allowedHashAlgorithms = self::DEFAULT_HASH_ALGORITHMS, private readonly ?int $clock = null, private readonly int $clockLeewaySeconds = 0, )verify(SdJwt|string $sdJwt, Verifier $issuerKey): VerifiedSdJwtverifyPresentation( SdJwt|string $presentation, Verifier $issuerKey, KeyBinding $keyBinding, ): VerifiedSdJwt
K2gl\SdJwt\VerifiedSdJwt class
__construct( private readonly stdClass $payload, private readonly ?stdClass $keyBindingPayload, private readonly array $disclosedPaths = [], private readonly array $undisclosedPaths = [], )payload(): stdClassclaims(): arraydisclosedPaths(): arrayundisclosedPaths(): arraykeyBindingPayload(): ?stdClass