sd-jwt: Selective Disclosure for JSON Web Tokens (RFC 9901)

[ bsd3, identity, json-web-token, jwt, library, privacy, program, rfc-9901, security, selective-disclosure ] [ Propose Tags ] [ Report a vulnerability ]

Implementation of RFC 9901: Selective Disclosure for JSON Web Tokens (SD-JWT)


[Skip to Readme]

Flags

Manual Flags

NameDescriptionDefault
interop-tests

Build interoperability test executable (not built by default)

Disabled

Use -f <flag> to enable a flag, or -f -<flag> to disable that flag. More info

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.1.0.0
Change log CHANGELOG.md
Dependencies aeson (>=2.0 && <2.3), base (>=4.14 && <5), base64-bytestring (>=1.2 && <1.3), bytestring (>=0.11 && <0.12), containers (>=0.6 && <0.7), cryptonite (>=0.30 && <0.31), directory (>=1.3 && <1.4), jose (>=0.10 && <0.13), lens (>=4.16 && <5.4), memory (>=0.18 && <0.19), mtl (>=2.2 && <3), scientific (>=0.3 && <0.4), sd-jwt, text (>=2.0 && <2.1), time (>=1.9 && <1.13), vector (>=0.13 && <0.14) [details]
License BSD-3-Clause
Copyright 2025 Yaron Sheffer
Author Yaron Sheffer
Maintainer yaronf.ietf@gmail.com
Uploaded by yaronf at 2026-01-09T22:26:55Z
Category Security
Home page https://github.com/yaronf/sd-jwt#readme
Bug tracker https://github.com/yaronf/sd-jwt/issues
Source repo head: git clone https://github.com/yaronf/sd-jwt
Distributions
Executables sd-jwt-interop-test, sd-jwt-example
Downloads 1 total (1 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs available [build log]
Last success reported on 2026-01-09 [all 1 reports]

Readme for sd-jwt-0.1.0.0

[back to package description]

SD-JWT: Selective Disclosure for JSON Web Tokens

CI Hackage Hackage deps

Haddocks License

Haskell implementation of RFC 9901: Selective Disclosure for JSON Web Tokens (SD-JWT).

Overview

SD-JWT enables selective disclosure of individual elements of a JSON data structure used as the payload of a JSON Web Signature (JWS). The primary use case is the selective disclosure of JSON Web Token (JWT) claims.

Features

  • ✅ SD-JWT issuance (issuer side)
  • ✅ SD-JWT presentation (holder side)
  • ✅ SD-JWT verification (verifier side)
  • ✅ Key Binding support (SD-JWT+KB)
  • ✅ Nested and recursive disclosures
  • ✅ Multiple hash algorithms (SHA-256, SHA-384, SHA-512)
  • ✅ Multiple signing algorithms: PS256 (RSA-PSS, default), RS256 (deprecated), ES256 (EC P-256), EdDSA (Ed25519)

Status

Stable - This implementation is feature-complete and ready for use.

The library implements RFC 9901 with comprehensive test coverage (224 tests). See internal-docs/IMPLEMENTATION_PLAN.md for implementation details.

Installation

stack build
# or
cabal build

Examples

A complete end-to-end example demonstrating the full SD-JWT flow (issuer → holder → verifier) is available:

stack exec sd-jwt-example
# or
stack runghc examples/EndToEndExample.hs

This example shows:

  • Issuer creating an SD-JWT with selective disclosure
  • Holder selecting which claims to disclose and creating a presentation
  • Verifier verifying the presentation and extracting claims

Usage

The library provides three persona-specific modules for different use cases:

For Issuers (Creating SD-JWTs)

⚠️ Security Warning: When using Elliptic Curve (EC) keys (ES256 algorithm), be aware that the underlying jose library's EC signing implementation may be vulnerable to timing attacks. This affects signing only, not verification. For applications where timing attacks are a concern, consider using RSA-PSS (PS256, default for RSA keys) or Ed25519 (EdDSA) keys instead.

Note: RS256 (RSA-PKCS#1 v1.5) is deprecated per draft-ietf-jose-deprecate-none-rsa15 due to padding oracle attack vulnerabilities. PS256 (RSA-PSS) is the recommended RSA algorithm and is used by default for RSA keys.

import SDJWT.Issuer
import qualified Data.Map.Strict as Map
import qualified Data.Aeson as Aeson
import qualified Data.Text as T

-- Create claims
let claims = Map.fromList
      [ ("sub", Aeson.String "user_123")
      , ("given_name", Aeson.String "John")
      , ("family_name", Aeson.String "Doe")
      ]

-- Load issuer's private key (can be Text or jose JWK object)
-- Example Text format: "{\"kty\":\"RSA\",\"n\":\"...\",\"e\":\"AQAB\",\"d\":\"...\"}"
issuerPrivateKeyJWK <- loadPrivateKeyJWK  -- Your function to load the key (returns Text or JWK.JWK)

-- Create SD-JWT with selective disclosure
-- PS256 (RSA-PSS) is used by default for RSA keys
-- createSDJWT signature: mbTyp mbKid hashAlg key claimNames claims
result <- createSDJWT (Just "sd-jwt") Nothing SHA256 issuerPrivateKeyJWK ["given_name", "family_name"] claims
case result of
  Right sdjwt -> do
    let serialized = serializeSDJWT sdjwt
    -- Send serialized SD-JWT to holder
  Left err -> putStrLn $ "Error creating SD-JWT: " ++ show err

For Holders (Creating Presentations)

import SDJWT.Holder
import qualified Data.Text as T
import Data.Int (Int64)

-- Deserialize SD-JWT received from issuer
case deserializeSDJWT sdjwtText of
  Right sdjwt -> do
    -- Select which disclosures to include in the presentation
    -- The holder chooses which claims to reveal (e.g., only "given_name", not "family_name")
    case selectDisclosuresByNames sdjwt ["given_name"] of
      Right presentation -> do
        -- The presentation now contains:
        -- - presentationJWT: The issuer-signed JWT (with digests for all claims)
        -- - selectedDisclosures: Only the disclosures for "given_name"
        -- Optionally add key binding (SD-JWT+KB) for proof of possession
        holderPrivateKeyJWK <- loadPrivateKeyJWK  -- Your function to load holder's private key (Text or jose JWK)
        let audience = "verifier.example.com"
        let nonce = "random-nonce-12345"
        let issuedAt = 1683000000 :: Int64
        result <- addKeyBindingToPresentation SHA256 holderPrivateKeyJWK audience nonce issuedAt presentation (Aeson.object [])
        case result of
          Right presentationWithKB -> do
            -- Serialize the presentation: JWT~disclosure1~disclosure2~...~KB-JWT
            -- This includes both the issuer-signed JWT and the selected disclosures
            let serialized = serializePresentation presentationWithKB
            -- Send serialized presentation to verifier
            -- The verifier will verify the signature and reconstruct claims from the selected disclosures
          Left err -> putStrLn $ "Error adding key binding: " ++ show err
      Left err -> putStrLn $ "Error selecting disclosures: " ++ show err
  Left err -> putStrLn $ "Error deserializing SD-JWT: " ++ show err

For Verifiers (Verifying SD-JWTs)

import SDJWT.Verifier
import qualified Data.Text as T

-- Deserialize presentation received from holder
case deserializePresentation presentationText of
  Right presentation -> do
    -- Load issuer's public key (can be Text or jose JWK object)
    issuerPublicKeyJWK <- loadPublicKeyJWK  -- Your function to load issuer's public key (Text or jose JWK)
    
    -- Verify the SD-JWT (optionally require specific typ header)
    -- Pass Nothing to allow any typ, or Just "sd-jwt" to require specific typ
    result <- verifySDJWT issuerPublicKeyJWK presentation Nothing
    case result of
      Right processedPayload -> do
        -- Extract claims
        let claims = processedClaims processedPayload
        -- Use verified claims
      Left err -> putStrLn $ "Verification failed: " ++ show err
  Left err -> putStrLn $ "Error deserializing presentation: " ++ show err

Advanced Usage

For library developers or advanced use cases requiring low-level access, import specific Internal modules as needed:

import SDJWT.Internal.Types
import SDJWT.Internal.Serialization
import SDJWT.Internal.Issuance
-- etc.

Nested Structures

The library supports nested structures using JSON Pointer syntax (RFC 6901), including both object properties and array elements:

let claims = Map.fromList
      [ ("address", Aeson.Object $ KeyMap.fromList
          [ (Key.fromText "street_address", Aeson.String "123 Main St")
          , (Key.fromText "locality", Aeson.String "City")
          , (Key.fromText "country", Aeson.String "US")
          ])
      , ("nationalities", Aeson.Array $ V.fromList
          [ Aeson.String "US"
          , Aeson.String "CA"
          , Aeson.String "UK"
          ])
      ]

-- Structured SD-JWT (Section 6.2): parent stays, sub-claims get _sd array
result <- buildSDJWTPayload SHA256 ["address/street_address", "address/locality"] claims

-- Recursive Disclosures (Section 6.3): parent is selectively disclosable
result <- buildSDJWTPayload SHA256 ["address", "address/street_address", "address/locality"] claims

-- Array elements: mark elements at indices 0 and 2 as selectively disclosable
result <- buildSDJWTPayload SHA256 ["nationalities/0", "nationalities/2"] claims

-- Mixed object and array paths
result <- buildSDJWTPayload SHA256 ["address/street_address", "nationalities/1"] claims

-- Nested arrays: mark element at index 0 of the array at index 0
result <- buildSDJWTPayload SHA256 ["nested_array/0/0", "nested_array/1/1"] claims

JSON Pointer Escaping

Keys containing forward slashes or tildes must be escaped using JSON Pointer syntax (RFC 6901):

  • ~1 = literal / (forward slash)
  • ~0 = literal ~ (tilde)

Important: When creating claims Maps, use the actual (unescaped) JSON keys. When passing claim names to buildSDJWTPayload, use escaped forms for keys containing special characters.

Examples:

  • Map key: "contact/email", path: ["contact~1email"] → marks literal key "contact/email" (not nested)
  • Map key: "user~name", path: ["user~0name"] → marks literal key "user~name" (not nested)
  • Map key: "address" (with nested "email"), path: ["address/email"] → marks email within address object (nested path)

Why escaping is necessary: Without escaping, there would be ambiguity between:

  • A literal key named "address/email"
  • The email key nested within an address object

JSON Pointer escaping resolves this ambiguity. See RFC 6901 for the complete specification.

Supported Algorithms

Signing Algorithms

  • PS256 (RSA-PSS) - Default for RSA keys, recommended for security
  • RS256 (RSA-PKCS#1 v1.5) - Deprecated per draft-ietf-jose-deprecate-none-rsa15, but still supported for backward compatibility
  • ES256 (EC P-256) - Elliptic Curve, may be vulnerable to timing attacks during signing
  • EdDSA (Ed25519) - Recommended for high-security applications

Note: RSA keys default to PS256. To use RS256, include "alg": "RS256" in your JWK.

Hash Algorithms

  • SHA-256 - Default algorithm
  • SHA-384
  • SHA-512

Key Format

Keys can be provided in two formats:

  1. Text (JSON string) - Most convenient, no need to import jose:

    let claims = Map.fromList [("claim", Aeson.String "value")]
    let issuerKey :: T.Text = "{\"kty\":\"RSA\",\"n\":\"...\",\"e\":\"AQAB\",\"d\":\"...\"}"
    -- createSDJWT takes: mbTyp mbKid hashAlg key claimNames claims
    result <- createSDJWT Nothing Nothing SHA256 issuerKey ["claim"] claims
    -- Or with typ header (recommended):
    result <- createSDJWT (Just "sd-jwt") Nothing SHA256 issuerKey ["claim"] claims
    
  2. jose JWK object - If you're already working with the jose library:

    import Crypto.JOSE.JWK as JWK
    let claims = Map.fromList [("claim", Aeson.String "value")]
    jwk <- loadJWK  -- Your function that returns JWK.JWK
    -- createSDJWT takes: mbTyp mbKid hashAlg key claimNames claims
    result <- createSDJWT Nothing Nothing SHA256 jwk ["claim"] claims
    -- Or with typ header (recommended):
    result <- createSDJWT (Just "sd-jwt") Nothing SHA256 jwk ["claim"] claims
    

The library automatically handles both formats through the JWKLike type class. Users who don't import jose can use Text strings directly, while users already working with jose can pass JWK objects without serialization overhead.

JWK JSON Format Example:

{
  "kty": "RSA",
  "n": "base64url-encoded-modulus",
  "e": "AQAB",
  "d": "base64url-encoded-private-exponent"
}

For public keys, omit the d field. See RFC 7517 for JWK format specification.

Documentation

License

BSD-3-Clause