Project

sd_jwt

0.0
The project is in a healthy, maintained state
RFC 9901 selective disclosure, recursive claims, JWS JSON serialization, and holder key binding, using ruby-jwt for signatures.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 2.19.4, < 4
>= 3.3, < 4
 Project Readme

SDJWT

Selective disclosure and holder key binding for Ruby.

Gem version CI Ruby 3.3 or later MIT License

Website · Installation · Quick start · Supported scope · Development · License

Issue, selectively present, and verify RFC 9901 SD-JWTs in Ruby, with signatures provided by ruby-jwt and OpenSSL.

Installation

Add the gem to your Gemfile:

gem "sd_jwt"

Then run bundle install.

Requires Ruby 3.3 or later, json >= 2.19.4, < 4, and jwt 3.3.x.

Quick start

require "sd_jwt"

issuer_key = OpenSSL::PKey::EC.generate("prime256v1")
holder_key = OpenSSL::PKey::EC.generate("prime256v1")
holder_jwk = JWT::JWK.new(holder_key).export.transform_keys(&:to_s)

issuer = SDJWT::Issuer.new(signing_key: issuer_key, alg: "ES256")
credential = issuer.issue(
  { "iss" => "https://issuer.example", "exp" => Time.now.to_i + 3600,
    "name" => "Example", "address" => { "street" => "Harbor Street", "city" => "Port" } },
  disclose: ["/name", "/address", "/address/street"],
  decoys: 2,
  holder_jwk: holder_jwk,
  header: { "typ" => "example+sd-jwt", "kid" => "key-1" }
)

held = SDJWT.parse(credential.to_s) # Unauthenticated holder-side parsing.
presentation = held.present(
  only: ["/name"],
  key_binding: { key: holder_key, alg: "ES256", aud: "https://verifier.example", nonce: "transaction-nonce" }
)

verifier = SDJWT::Verifier.new(
  algorithms: ["ES256"],
  issuer_key: issuer_key, # Or ->(untrusted_header, untrusted_payload) { trusted_key_lookup(...) }
  issuer: "https://issuer.example",
  required_claims: ["iss", "exp", "cnf"],
  require_key_binding: true,
  audience: "https://verifier.example",
  nonce: "transaction-nonce",
  max_kb_age: 300
)
result = verifier.verify(presentation)
result.claims                    # Immutable, authenticated disclosed claims.
result.key_binding_verified      # true

Claim and header hashes require string keys and JSON values. Generation rejects reserved _sd, _sd_alg, and ... members. SdJwt is an alias for SDJWT.

Use token.disclosed_paths to list available disclosures. present(only:) includes parents required by selected children, but selecting a parent does not reveal separately hidden children. Array indices refer to the original payload; hidden entries disappear from the verified result. An empty selection sends no disclosures. Each new presentation removes any existing KB-JWT; pass key_binding: to bind it again.

decoys: adds digests to each object's _sd array, including disclosed objects, without adding dummy array elements. Digest arrays are sorted, salts contain 128 random bits, and max_disclosures bounds the combined disclosure and decoy count.

Supported scope

Feature Support
RFC 9901 Compact, Flattened JSON, General JSON Read, write, present, verify
Object claims, array entries, recursive disclosure Supported
Unknown application claims and header parameters Preserved
Hashes sha-256 default; sha-384 and sha-512 require verifier opt-in
Issuer signatures ES256/384/512, RS256/384/512, PS256/384/512; HS256/384/512 only when explicitly configured
Holder key binding cnf.jwk with public EC or RSA keys, KB-JWT type/time/audience/nonce/presentation digest
JSON duplicate names, non-finite values, invalid UTF-8 Rejected with both json 2.x and 3.x
SD-JWT VC / W3C VC profile rules, revocation, trust discovery Application responsibility
EdDSA, symmetric holder keys, other cnf methods, custom JOSE critical extensions, unencoded payloads Unsupported
CBOR SD-CWT Unsupported

none is always rejected. Holder keys must be P-256/P-384/P-521 or RSA with a 2048–8192 bit odd modulus and an odd exponent of at least 3. RSA issuer keys require at least 2048 bits and a valid exponent. HMAC requires random secrets of at least 32/48/64 bytes for HS256/384/512; PEM, DER, and SSH asymmetric key encodings are rejected as HMAC secrets. Use key_binding_algorithms: to further restrict holder algorithms.

JWS JSON

flattened = credential.to_json(format: :flattened)
general = credential.to_json(format: :general)
verifier.verify(SDJWT.parse(flattened).present(only: ["/name"], key_binding: {
  key: holder_key, alg: "ES256", aud: "https://verifier.example", nonce: "transaction-nonce"
}))

General JSON verification requires every signature to pass. Disclosures and kb_jwt belong to the first signature's unprotected header; all signatures share those disclosures, and key binding covers the first signature's Compact representation. Presentations retain additional signatures. Multi-signature tokens require General JSON; tokens with other unprotected headers must also remain JSON. Issuance creates one signature; externally co-signed tokens are accepted.

Unprotected headers are not identity evidence, and key resolver inputs are not yet authenticated. Resolve iss/kid against an explicit trust configuration; never fetch arbitrary header URLs. headers merges verified signature headers; inspect signatures to distinguish protected from unprotected metadata.

Verification policy and limits

Verifier.new requires a nonempty signing algorithm allowlist. required_claims: defines validity claims that must survive disclosure; hidden exp, aud, or cnf claims cannot otherwise be detected. issuer: requires an exact issuer match. A present credential aud must match audience:; to require its presence, add aud to required_claims. exp and nbf are checked after disclosure reconstruction; leeway: defaults to zero.

Set require_key_binding: true based on your application's policy, not on whether a KB-JWT happens to arrive. With it enabled, a nonempty audience: and nonce: are required, and iat must fall between the current time minus max_kb_age and the current time. Without it, any supplied KB-JWT is structurally parsed but does not establish holder authentication; result.key_binding_verified remains false. Nonce generation, transaction replay storage, key revocation, credential profile rules, and required typ values belong to the application.

Default limits are 1 MiB serialized input, 1000 disclosures, nesting depth 32, and 16 General signatures. Configure max_bytes:, max_disclosures:, max_depth:, and max_signatures: on parsing/verifying; issuance accepts the first three. Verifying an existing Token reapplies the verifier's limits. Cryptographic inputs always use strict parsing; strict: false is rejected rather than downgrading errors to warnings.

Failures use SDJWT::ParseError, ValidationError (with pointer for disclosure path failures), ConversionError, or VerificationError, all under SDJWT::Error. Library errors omit claim values and salts. Application callback errors remain the application's responsibility.

Development

bundle install
bundle exec rake test
COVERAGE=1 bundle exec rake test
bundle exec rbs -I sig validate
bundle exec ruby script/check_syntax.rb
bundle exec ruby script/check_licenses.rb
bundle exec ruby script/benchmark.rb

The first test run downloads checksum-pinned RFC fixtures into ignored tmp/ directories and requires network access.

For deterministic tests only, explicitly require sd_jwt/testing and wrap issuance in SDJWT::Testing.with_salts([...]). Never use supplied salts in production.

Contributing

Bug reports and pull requests are welcome on GitHub. See Development for local checks and SECURITY.md for private vulnerability reporting and audit status.

License

Released under the MIT License.