JWTiny: Minimal JWT Validation for Rust

December 4th, 2025 1340 Words

I was learning Rust with an example project that needed JWT validation. The popular jsonwebtoken crate depends on serde, but I wanted miniserde instead. That constraint led me to build my own validator — handling signature verification, claims validation, and remote key fetching, designed for reuse across requests. JWTiny is the result.

The Problem with JWT Validation

Implementing JWT validation correctly means handling several concerns:

  • Verifying the token signature using RSA public keys
  • Validating standard claims like exp, nbf, and iat
  • Checking issuer and audience requirements
  • Fetching keys from JWKS endpoints when using remote identity providers
  • Caching keys to avoid repeated network requests
  • Supporting multiple algorithms (RS256, RS384, RS512)

Most libraries either do too much or too little. You end up either pulling in unnecessary dependencies or writing boilerplate for key fetching and caching.

A Reusable Validation Pattern

JWTiny follows a simple pattern: configure the validator once at application startup, then verify tokens with minimal overhead. The validator can be shared across requests, which reduces memory footprint and improves performance. This design makes it perfect for high-throughput services where every allocation matters.

The library supports RSA algorithms (RS256, RS384, RS512) with an aws-lc-rs backend, and provides JWKS support for remote key fetching over HTTPS (rustls) with caching. It’s been tested with Axum, Poem, Rocket, and Warp, so it fits into your existing Rust web stack.

Installation

Add JWTiny to your Cargo.toml:

cargo add jwtiny

That’s it. The library has minimal dependencies and focuses on doing one thing well.

Quick Start: Static Key Validation

When you have a static public key, configure the validator like this:

use jwtiny::{AlgorithmPolicy, ClaimsValidation, TokenValidator};

let validator = TokenValidator::new()
    .algorithms(AlgorithmPolicy::rs512_only())
    .validate(ClaimsValidation::default())
    .key(&public_key_der)
    .build();

let claims = validator.verify(token).await?;

The validator is ready to use. You can call verify() as many times as you need—the library is designed for reuse. Configure it once, share it across your application, and verify tokens efficiently.

JWKS Validation: Remote Key Fetching

You’ll often want to fetch keys from a JWKS endpoint. JWTiny handles this with built-in caching:

use jwtiny::{AlgorithmPolicy, ClaimsValidation, RemoteCacheKey, TokenValidator};
use moka::future::Cache;
use std::{sync::Arc, time::Duration};

let client = reqwest::Client::new();
let cache = Arc::new(
    Cache::<RemoteCacheKey, Vec<u8>>::builder()
        .time_to_live(Duration::from_secs(300))
        .max_capacity(1000)
        .build()
);

let validator = TokenValidator::new()
    .algorithms(AlgorithmPolicy::rs512_only())
    .issuer(|iss| iss == "https://auth.example.com")
    .validate(ClaimsValidation::default().require_audience("my-api"))
    .jwks(client)
    .cache(cache)
    .build();

let claims = validator.verify(token).await?;

The cache reduces network requests and improves performance. Set the TTL to match the key rotation schedule of your identity provider. For testing, this works perfectly with JWKServe as well—you can spin up a local JWKS endpoint, generate tokens with specific claims for your test scenarios, and validate them without any external dependencies.

Custom Claims with Type Safety

If you need custom claim structures, use the #[claims] macro:

use jwtiny::{claims, AlgorithmPolicy, ClaimsValidation, TokenValidator};

#[claims]
struct MyClaims {
    pub role: String,
    pub permissions: Vec<String>,
}

let validator = TokenValidator::new()
    .algorithms(AlgorithmPolicy::rs256_only())
    .validate(ClaimsValidation::default())
    .key(&public_key_der)
    .build();

let claims = validator.verify_with_custom::<MyClaims>(token).await?;

The macro handles the standard claims (iss, sub, aud, exp, nbf, iat, jti) automatically, so you only need to define your custom fields. The result is type-safe access to your claims without manual JSON parsing.

API Reference

TokenValidator

Configure the validator once, then reuse it for multiple verifications:

let validator = TokenValidator::new()
    .algorithms(AlgorithmPolicy::rs512_only())  // RS256, RS384, RS512, or rsa_all()
    .issuer(|iss| iss == "https://auth.example.com")
    .validate(ClaimsValidation::default().require_audience("my-api"))
    .key(&public_key_der)      // Static key (mutually exclusive with jwks)
    .jwks(client)              // JWKS (mutually exclusive with key)
    .cache(cache)              // Optional: cache JWKS keys
    .build();

// Verify tokens (reusable)
let claims = validator.verify(token_str).await?;
let custom = validator.verify_with_custom::<MyClaims>(token_str).await?;

AlgorithmPolicy

Control which algorithms are accepted:

AlgorithmPolicy::rs256_only()  // RS256 only
AlgorithmPolicy::rs512_only()  // RS512 only
AlgorithmPolicy::rsa_all()     // All RSA (default)

ClaimsValidation

Configure temporal and audience validation:

ClaimsValidation::default()
    .require_audience("my-api")
    .max_age(3600)
    .clock_skew(60)
    .no_exp_validation()
    .no_nbf_validation()
    .no_iat_validation()

By default, the validator checks expiration (exp), not-before (nbf), and issued-at (iat), with a max age of 30 minutes and no clock skew. In distributed systems, adding clock skew tolerance can help handle time synchronisation differences.

Error Handling

All validation errors are returned as jwtiny::Error:

match validator.verify(token).await {
    Ok(claims) => println!("Valid: {:?}", claims),
    Err(jwtiny::Error::TokenExpired { .. }) => eprintln!("Token expired"),
    Err(jwtiny::Error::SignatureInvalid) => eprintln!("Invalid signature"),
    Err(e) => eprintln!("Validation failed: {:?}", e),
}

The error types are specific enough to handle different failure modes appropriately, whether it’s an expired token, invalid signature, or missing claims.

Integrating with Your Web Framework

The validator doesn’t care about your web framework—it just validates tokens. This means integrating it into your application follows a consistent pattern regardless of whether you’re using Axum, Poem, Rocket, or Warp.

The pattern is straightforward: configure the validator once at application startup, store it in your application state, extract the token from the Authorization header in your framework’s authentication mechanism, verify it using the shared validator, and make the claims available to your handlers. Since the validator is designed for reuse, you’re not creating new instances for each request.

Let me show you how this works with Rocket, which uses request guards — a pattern that fits naturally with JWTiny’s approach. The same principles apply to other frameworks, and you can find complete examples for Axum, Poem, and Warp in the jwtiny repository.

Rocket: Request Guards and Custom Claims

Rocket’s request guard system makes JWT validation feel native. You implement FromRequest for an Authenticated type, and Rocket handles the rest. Here’s a complete example that also demonstrates custom claims:

use jwtiny::{claims, AlgorithmPolicy, ClaimsValidation, RemoteCacheKey, TokenValidator};
use moka::future::Cache;
use rocket::{
    http::Status,
    request::{FromRequest, Outcome, Request},
};
use std::{sync::Arc, time::Duration};

#[claims]
pub struct CustomClaims {
    #[serde(rename = "email")]
    pub email: Option<String>,
    #[serde(rename = "role")]
    pub role: Option<String>,
    #[serde(rename = "permission_list")]
    pub permissions: Option<Vec<String>>,
}

#[derive(Clone)]
struct AppState {
    validator: TokenValidator,
}

pub struct Authenticated(pub CustomClaims);

#[rocket::async_trait]
impl<'r> FromRequest<'r> for Authenticated {
    type Error = ();

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let state = request.rocket().state::<AppState>()
            .ok_or(Status::InternalServerError)?;

        let token_str = request.headers()
            .get_one("authorization")
            .ok_or(Status::Unauthorized)?
            .split_whitespace()
            .nth(1)
            .ok_or(Status::BadRequest)?;

        let claims = state.validator
            .verify_with_custom::<CustomClaims>(token_str)
            .await
            .map_err(|_| Status::Unauthorized)?;

        Outcome::Success(Authenticated(claims))
    }
}

#[rocket::get("/")]
async fn handler(auth: Authenticated) -> String {
    let subject = auth.0.subject.as_ref()
        .map(|s| s.to_string())
        .unwrap_or_else(|| "unknown".to_string());
    format!("Hello, World! You are authorized: {subject}")
}

#[rocket::launch]
async fn rocket() -> _ {
    let client = reqwest::Client::new();
    let cache = Arc::new(
        Cache::<RemoteCacheKey, Vec<u8>>::builder()
            .time_to_live(Duration::from_secs(300))
            .max_capacity(1000)
            .build()
    );

    let validator = TokenValidator::new()
        .algorithms(AlgorithmPolicy::rs512_only())
        .validate(ClaimsValidation::default())
        .jwks(client)
        .cache(cache)
        .build();

    let state = AppState { validator };

    rocket::build()
        .manage(state)
        .mount("/", rocket::routes![handler])
}

What’s happening here? The Authenticated request guard extracts the token from the Authorization header, uses the shared validator to verify it, and returns the custom claims. If validation fails, Rocket automatically returns the appropriate HTTP status. In your handlers, you simply add Authenticated as a parameter, and Rocket ensures the request is authenticated before your handler runs.

The same pattern works across frameworks, just with different mechanisms. Axum uses middleware, Poem uses around handlers, and Warp uses filters. Each framework has its own way of accessing application state and processing requests, but the core flow remains the same: extract token, verify with shared validator, make claims available. You can run the Rocket example with cargo run -p jwtiny-example-rocket, or explore the other framework examples in the repository to see how they adapt this pattern to their specific idioms.

Production Considerations

JWTiny is intended for reliable use. The library:

  • Uses aws-lc-rs for cryptographic operations (a BoringSSL fork used by AWS)
  • Supports rustls for HTTPS connections (no OpenSSL dependency)
  • Provides efficient key caching to reduce network overhead
  • Minimises allocations during token verification
  • Follows RFC 7515, RFC 7519, and RFC 8725 best practices

The reusable validator pattern means you’re not creating new validators for each request, which reduces memory pressure and improves performance in high-throughput scenarios.

That’s It! 🎉

What started as a learning project to avoid serde dependencies turned into a validator that’s both minimal and complete. JWTiny gives you JWT validation with JWKS support, all while keeping your dependency tree lean. The validator pattern — configure once, reuse everywhere — fits naturally into web applications, whether you’re using Axum, Poem, Rocket, or Warp.

If you’re building Rust backends that need JWT validation, give JWTiny a try. You can find the project on GitHub and install it via cargo add jwtiny. The library is MIT licensed and ready for use.


View on GitHub Source code is published using the MIT License.