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.

k2gl/sd-jwt version on Packagist k2gl/sd-jwt total downloads k2gl/sd-jwt license

Install

composer require k2gl/sd-jwt
PHP
>=8.1
k2gl dependencies
k2gl/dsse

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.

Scope

  • Compact serialization (<JWT>~<Disclosure>~...~[<KB-JWT>]) — 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-cnf match, iat window, 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.

The JWS JSON serialization (RFC 9901 Section 8) is not implemented yet.

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 (stdClass vs 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): self
  • forProperty(string $salt, string $claimName, mixed $value): self
  • forArrayElement(string $salt, mixed $value): self
  • isArrayElement(): bool
  • digest(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): self
  • es384FromPem(string $pem): self
  • es512FromPem(string $pem): self
  • ed25519(string $secretKey): self
  • rs256FromPem(string $pem): self
  • withAlgorithm(Signer $signer, string $algorithm): self
  • algorithm(): string
  • sign(array|stdClass $payload, array $header = []): string

K2gl\SdJwt\KeyBinding class

  • required(string $audience, string $nonce, int $maxAgeSeconds = 600): self
  • notRequired(): self

K2gl\SdJwt\Presentation class

  • of(SdJwt|string $sdJwt): self
  • payload(): stdClass
  • claims(): array
  • disclosablePaths(): array
  • disclose(string ...$paths): self
  • discloseAll(): self
  • toSdJwt(): SdJwt
  • toCompact(): string
  • withKeyBinding( JwsSigner $signer, string $audience, string $nonce, ?int $issuedAt = null, ): string

K2gl\SdJwt\Sd class

  • hide(mixed $value): self
  • decoy(): self

K2gl\SdJwt\SdJwt class

  • parse(string $compact): self
  • create(string $issuerSignedJwt, array $disclosures, ?string $keyBindingJwt = null): self
  • hasKeyBinding(): bool
  • withoutKeyBinding(): self
  • header(): stdClass
  • payload(): stdClass
  • toCompact(): string

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): VerifiedSdJwt
  • verifyPresentation( SdJwt|string $presentation, Verifier $issuerKey, KeyBinding $keyBinding, ): VerifiedSdJwt

K2gl\SdJwt\VerifiedSdJwt class

  • __construct( private readonly stdClass $payload, private readonly ?stdClass $keyBindingPayload, )
  • payload(): stdClass
  • claims(): array
  • keyBindingPayload(): ?stdClass