jwtiny 1.3.0

Minimal, type-safe JWT validation library
Documentation

jwtiny

crates.io MIT licensed CI CI

Minimal, type-safe JSON Web Token (JWT) validation for Rust.

jwtiny validates JWT tokens through a builder-pattern API that attempts to enforce correct validation order at compile time. Initially created to explore miniserde support for common JWT libraries, this ended up as a full library.

Warning: This is a learning project to get more familiar with Rust. If you spot any flaws, let me know. If you can make any use of this, let me know as well! πŸŽ‰

Overview

JWTs (JSON Web Tokens) encode claims as JSON objects secured by digital signatures or message authentication codes. Validating them requires parsing Base64URL-encoded segments, verifying signatures with cryptographic keys, and checking temporal claims like expiration.

Common pitfalls include algorithm confusion attacks (accepting asymmetric algorithms when only symmetric keys are trusted), server-side request forgery (SSRF) via untrusted issuer URLs, and timing vulnerabilities in signature comparison.

jwtiny attempts to address these through a type-safe state machine: parsing yields a ParsedToken, issuer validation produces a TrustedToken, signature verification creates a VerifiedToken, and claims validation returns the final Token. Each stage must complete before the next begins, enforced by Rust's type system. The builder pattern configures all steps upfront, then executes them atomicallyβ€”aiming to prevent partial validation and ensure cryptographic keys are only used after issuer checks complete.

Features

Per default, only hmac support is enabled. Everything else needs to be opt-in enabled as features:

Feature Feature Description
hmac always enabled HMAC algorithms (HS256, HS384, HS512)
rsa βœ… RSA algorithms (RS256, RS384, RS512)
ecdsa βœ… ECDSA algorithms (ES256, ES384)
aws-lc-rs βœ… Use aws-lc-rs backend instead of ring for RSA/ECDSA
all-algorithms βœ… Enable all asymmetric algorithms (RSA + ECDSA)
remote βœ… Remote JWKS over HTTPS (rustls). Provide an HTTP client.

Quick Start

Add jwtiny to your Cargo.toml:

[dependencies]
jwtiny = "0.0.0"

Note: Current version is 0.0.0 (pre-release). Update to latest published version when available.

For asymmetric algorithms (RSA, ECDSA), enable features:

jwtiny = { version = "0.0.0", features = ["rsa", "ecdsa"] }

Minimal example validating an HMAC-signed token:

use jwtiny::*;

let token = TokenValidator::new(
    ParsedToken::from_string(token_str)?
)
    .ensure_issuer(|iss| Ok(iss == "https://trusted.com"))
    .verify_signature(SignatureVerification::with_secret_hs256(b"secret"))
    .validate_token(ValidationConfig::default())
    .run()?;

println!("Subject: {:?}", token.subject());

Examples

HMAC Validation

For tokens signed with symmetric keys (HS256, HS384, HS512):

use jwtiny::*;

let token = TokenValidator::new(ParsedToken::from_string(token_str)?)
    .danger_skip_issuer_validation() // Only if providing key directly
    .verify_signature(SignatureVerification::with_secret_hs256(b"your-256-bit-secret"))
    .validate_token(ValidationConfig::default())
    .run()?;

RSA Public Key Validation

Requires the rsa feature:

use jwtiny::*;

let token = TokenValidator::new(ParsedToken::from_string(token_str)?)
    .ensure_issuer(|iss| Ok(iss == "https://auth.example.com"))
    .verify_signature(SignatureVerification::with_rsa_rs256(public_key_der))
    .validate_token(ValidationConfig::default())
    .run()?;

ECDSA Public Key Validation

Requires the ecdsa feature. Supports P-256 and P-384 curves:

use jwtiny::*;

let token = TokenValidator::new(ParsedToken::from_string(token_str)?)
    .ensure_issuer(|iss| Ok(iss == "https://auth.example.com"))
    .verify_signature(SignatureVerification::with_ecdsa_es256(public_key_der))
    .validate_token(ValidationConfig::default())
    .run()?;

JWKS Flow (Remote Key Fetching)

Requires the remote feature. Fetch public keys from a JWKS endpoint:

use jwtiny::*;
use jwtiny::remote::HttpClient;

// Create an HTTP client function pointer
let http_client: HttpClient = {
    let client = reqwest::Client::new();
    Box::new(move |url: String| {
        let client = client.clone();
        Box::pin(async move {
            let response = client
                .get(&url)
                .send()
                .await
                .map_err(|e| Error::RemoteError(format!("network: {}", e)))?;
            
            if !response.status().is_success() {
                return Err(Error::RemoteError(
                    format!("http: status {}", response.status())
                ));
            }
            
            response.bytes().await
                .map_err(|e| Error::RemoteError(format!("network: {}", e)))
                .map(|b| b.to_vec())
        })
    })
};

// Validate with automatic key resolution from JWKS
let token = TokenValidator::new(ParsedToken::from_string(token_str)?)
    .ensure_issuer(|iss| {
        // CRITICAL: Validate issuer before fetching keys
        if iss == "https://auth.example.com" {
            Ok(())
        } else {
            Err(Error::IssuerNotTrusted(iss.to_string()))
        }
    })
    .verify_signature(
        SignatureVerification::with_jwks(
            http_client,
            AlgorithmPolicy::recommended_asymmetric(), // RS256 + ES256
            true  // use_cache
        )
    )
    .validate_token(ValidationConfig::default())
    .run_async()
    .await?;

Security note: Always validate the issuer before enabling JWKS fetching. Without issuer validation, an attacker can craft a token with an arbitrary iss claim, causing your application to fetch keys from attacker-controlled URLsβ€”a classic SSRF vulnerability.

API Overview

The validation flow proceeds through distinct stages, each producing a new type:

// Stage 1: Parse the token string
let parsed = ParsedToken::from_string(token_str)?;

// Stage 2: Build the validation pipeline
let token = TokenValidator::new(parsed)
    .ensure_issuer(/* closure */)      // Required: validate issuer (or use .danger_skip_issuer_validation())
    .verify_signature(/* config */)    // Required: verify signature
    .validate_token(/* config */)      // Optional: defaults to ValidationConfig::default() if omitted
    .run()?;                           // Execute all stages atomically

// Stage 3: Access validated claims
token.subject();    // Option<&str>
token.issuer();     // Option<&str>
token.claims();     // &Claims

Issuer Validation

Always validate issuers when using JWKS to prevent SSRF attacks:

// βœ… Correct: Allowlist trusted issuers
.ensure_issuer(|iss| {
    let trusted = ["https://auth.example.com", "https://login.example.org"];
    trusted.contains(&iss)
        .then_some(())
        .ok_or(Error::IssuerNotTrusted(iss.to_string()))
})

// For same-service tokens, explicitly skip
.danger_skip_issuer_validation()

Signature Verification

Choose verification based on the algorithm family:

HMAC (symmetric keys) β€” always enabled:

SignatureVerification::with_secret_hs256(b"your-256-bit-secret")

RSA (asymmetric keys) β€” requires rsa feature:

SignatureVerification::with_key(
    Key::rsa_public(public_key_der),
    AlgorithmPolicy::rs256_only(),
)

ECDSA (asymmetric keys) β€” requires ecdsa feature:

SignatureVerification::with_ecdsa_es256(public_key_der)

Use algorithm-specific constructors (preferred) or pass an explicit AlgorithmPolicy.

Claims Validation

Configure temporal and claim-specific checks:

ValidationConfig::default()
    .require_audience("my-api")           // Validate `aud` claim
    .max_age(3600)                        // Token must be < 1 hour old
    .clock_skew(60)                       // Allow 60s clock skew
    .no_exp_validation()                  // Skip expiration (dangerous)
    .custom(|claims| {                    // Custom validation logic
        if claims.subject.as_deref() != Some("admin") {
            Err(Error::ClaimValidationFailed(
                ClaimError::Custom("Admin only".to_string())
            ))
        } else {
            Ok(())
        }
    })

Architecture

The library enforces a validation pipeline through type-level state transitions:

ParsedToken (parsed header and payload)
    β”‚ .ensure_issuer()
    β–Ό
TrustedToken (issuer validated; internal type)
    β”‚ .verify_signature()
    β–Ό
VerifiedToken (signature verified; internal type)
    β”‚ .validate_token()
    β–Ό
ValidatedToken (claims validated; internal type)
    β”‚ .run() / .run_async()
    β–Ό
Token (public API; safe to use)

Only the final Token type is exposed publicly. Intermediate types (TrustedToken, VerifiedToken, ValidatedToken) are internal, which helps prevent partial validation from escaping the builder.

Algorithm Confusion Prevention

Always restrict algorithms explicitly; an explicit policy is required. Prefer algorithm-specific constructors:

// βœ… Correct: Only allow the algorithm you trust
SignatureVerification::with_secret_hs256(b"your-256-bit-secret")

// ❌ Incorrect: missing explicit policy
// SignatureVerification::with_secret(b"secret")

SSRF Prevention

When using JWKS, validate issuers before fetching keys:

// βœ… Correct: Allowlist trusted issuers
.ensure_issuer(|iss| {
    let allowed = ["https://trusted.com", "https://auth.example.com"];
    allowed.contains(&iss)
        .then_some(())
        .ok_or(Error::IssuerNotTrusted(iss.to_string()))
})

// ❌ Incorrect: Attacker can make you fetch from any URL
.danger_skip_issuer_validation()  // Dangerous with JWKS!

"none" Algorithm Rejection

The "none" algorithm (unsigned tokens) is always rejected per RFC 8725:

ParsedToken::from_string("eyJhbGciOiJub25lIn0...")  
// Returns: Error::NoneAlgorithmRejected

Timing Attack Protection

HMAC signature verification uses constant-time comparison via the constant_time_eq crate, which aims to mitigate timing-based key recovery attacks.

Cryptographic Backends

jwtiny supports two backends for RSA and ECDSA:

  1. ring (default) β€” battle-tested cryptography library
  2. aws-lc-rs β€” FIPS-validated AWS cryptography library

Select exactly one backend. The choice affects signature verification compatibility.

Using ring (default)

[dependencies]
jwtiny = { version = "0.0.0", features = ["rsa", "ecdsa"] }

Using aws-lc-rs

[dependencies]
jwtiny = { version = "0.0.0", features = ["rsa", "ecdsa", "aws-lc-rs"] }

Compatibility note: If you're verifying tokens signed by services using jsonwebtoken with the aws_lc_rs feature (e.g., jwkserve), use the aws-lc-rs feature to ensure compatibility.

Testing

jwtiny includes test coverage across algorithm families, edge cases, and integration scenarios.

Running Tests

# All features with default backend
cargo test --lib --tests --bins --examples --all-features

# Specific algorithm features
cargo test --lib --tests --bins --examples
cargo test --lib --tests --bins --examples --features rsa
cargo test --lib --tests --bins --examples --features ecdsa

# aws-lc-rs backend (for compatibility testing)
cargo test --lib --tests --bins --examples --features rsa,aws-lc-rs
cargo test --lib --tests --bins --examples --features ecdsa,aws-lc-rs

# Remote JWKS fetching
cargo test --lib --tests --bins --examples --features remote,rsa

# Run specific test suite
cargo test --test algorithm_round_trips --features all-algorithms
cargo test --test jwkserve_integration --features remote,rsa,aws-lc-rs
cargo test --test edge_cases

Test Coverage

  • Algorithm tests (tests/algorithm_round_trips.rs): Round-trip signing and verification for HMAC, RSA, and ECDSA
  • Integration tests (tests/jwkserve_integration.rs): End-to-end RS256 verification via JWKS (requires Docker)
  • Edge cases (tests/edge_cases.rs): Token format validation, Base64URL edge cases, claims validation, algorithm confusion prevention
  • JWK support (tests/jwk_support.rs): JWK metadata handling, key selection, RSA/ECDSA key extraction
  • JWT.io compatibility (tests/jwtio_compatibility.rs): Verification of canonical JWT.io example tokens
  • Custom headers (tests/custom_headers.rs): Header field preservation (kid, typ, custom fields), field order invariance, real-world header formats
  • Key formats (tests/key_formats.rs): PKCS#8 DER, PKCS#1 DER, PEM format conversion, invalid/truncated key handling

Running Examples

cargo run --example basic
cargo run --example multi_algorithm --features all-algorithms

License

MIT

References