JSON Web Tokens (JWT)

JWT is a compact, URL-safe token format for transmitting claims between parties. A JWT has three base64url-encoded parts separated by dots: header.payload.signature.

Structure

Declares the token type and signing algorithm:

{
  "alg": "HS256",
  "typ": "JWT"
}

Common algorithms:

  • HS256 (HMAC-SHA256) — symmetric. Same secret signs and verifies. Simple, fast, but the secret must be shared between issuer and verifier.
  • RS256 (RSA-SHA256) — asymmetric. Private key signs, public key verifies. Useful when the verifier shouldn’t be able to issue tokens.
  • ES256 (ECDSA-SHA256) — asymmetric, shorter keys than RSA for equivalent security.

Payload (Claims)

The actual data. Standard claims (all optional):

  • sub — subject (who the token is about, e.g. user ID)
  • exp — expiration time (Unix timestamp). Decoders reject expired tokens automatically.
  • iat — issued at
  • iss — issuer
  • aud — audience (who the token is intended for)
  • nbf — not before (token invalid before this time)

You add custom claims alongside these. The payload is not encrypted — anyone can decode it. The signature only guarantees it wasn’t tampered with.

Signature

HMAC-SHA256(base64url(header) + "." + base64url(payload), secret) for HS256. The decoder recomputes this and rejects the token if it doesn’t match.

The jsonwebtoken Crate in Rust

Encoding (creating tokens)

use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize};
 
#[derive(Serialize, Deserialize)]
struct Claims {
    sub: String,
    role: String,
    exp: usize,  // required — Unix timestamp
}
 
let claims = Claims {
    sub: "alice".to_string(),
    role: "admin".to_string(),
    exp: 9_999_999_999,
};
 
// Header::default() gives { "alg": "HS256", "typ": "JWT" }
// For other algorithms: Header::new(Algorithm::RS256)
let token = encode(
    &Header::default(),
    &claims,
    &EncodingKey::from_secret(b"my-secret"),
).unwrap();

EncodingKey variants:

  • from_secret(bytes) — for HMAC (HS256/HS384/HS512)
  • from_rsa_pem(pem_bytes) — for RSA private key
  • from_ec_pem(pem_bytes) — for ECDSA private key

Decoding (validating tokens)

use jsonwebtoken::{decode, DecodingKey, Validation};
 
let token_data = decode::<Claims>(
    &token,
    &DecodingKey::from_secret(b"my-secret"),
    &Validation::default(),
).unwrap();
 
// decode returns TokenData<T>, not T directly:
// pub struct TokenData<T> {
//     pub header: Header,
//     pub claims: T,
// }
let user_id = token_data.claims.sub;
let header_alg = token_data.header.alg;

Validation

Validation::default() enables:

  • Algorithm check (HS256 only)
  • Expiration (exp) — rejects expired tokens
  • nbf (not before) check

To customize:

let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = false;          // disable expiration check (e.g. for tests)
validation.set_audience(&["my-api"]);     // require specific `aud` claim
validation.set_issuer(&["auth-server"]); // require specific `iss` claim
validation.leeway = 60;                   // 60 seconds of tolerance on exp/nbf

Validation::default() does NOT validate iss or aud — you must opt in.

Error handling

decode() returns Result<TokenData<T>, jsonwebtoken::errors::Error>. Common error kinds:

  • ExpiredSignature — token past exp
  • InvalidToken — malformed
  • InvalidSignature — signature doesn’t match
  • InvalidAlgorithm — header says one algorithm, validation expects another

JWT in Axum middleware

The typical pattern for HTTP API authentication:

  1. Client sends Authorization: Bearer <token> header
  2. Middleware extracts and decodes the token
  3. On success, inserts a CurrentUser into request extensions
  4. Handlers access it via Extension<CurrentUser>
  5. On failure, returns 401 Unauthorized before reaching the handler
use axum::extract::{Extension, Request};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
 
#[derive(Clone)]
struct CurrentUser {
    id: String,
    role: String,
}
 
async fn auth_middleware(mut req: Request, next: Next) -> Response {
    let token = req.headers()
        .get(http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "));
 
    let token = match token {
        Some(t) => t,
        None => return (StatusCode::UNAUTHORIZED, "Missing token").into_response(),
    };
 
    match decode::<Claims>(token, &DecodingKey::from_secret(SECRET), &Validation::default()) {
        Ok(data) => {
            req.extensions_mut().insert(CurrentUser {
                id: data.claims.sub,
                role: data.claims.role,
            });
            next.run(req).await
        }
        Err(_) => (StatusCode::UNAUTHORIZED, "Invalid token").into_response(),
    }
}
 
// Apply to routes:
// Router::new()
//     .route("/me", get(me))
//     .route_layer(middleware::from_fn(auth_middleware))

JWKS: Dynamic Key Discovery

JWKS (JSON Web Key Set) solves the key distribution problem for asymmetric JWT verification. Without it, you’d hardcode the auth provider’s public key in your app and redeploy every time they rotate keys.

How it works

The auth provider (Descope, Auth0, Keycloak) publishes its public keys at a well-known URL:

https://api.descope.com/v2/keys/{project_id}

The response is a JSON array of keys, each identified by a kid (Key ID):

{
  "keys": [
    { "kid": "key-1", "kty": "RSA", "n": "...", "e": "AQAB" },
    { "kid": "key-2", "kty": "RSA", "n": "...", "e": "AQAB" }
  ]
}

When a JWT arrives:

  1. Read the kid from the JWT header (not the payload — the header is unverified metadata)
  2. Look up that key in the JWKS
  3. Verify the JWT’s signature using that specific key

Key rotation transparency

The provider can rotate keys without coordinating with consumers:

  1. Provider adds key-3 to the JWKS endpoint
  2. Provider starts signing new JWTs with key-3
  3. Old JWTs signed with key-1 or key-2 still verify (those keys remain in JWKS)
  4. Eventually provider removes key-1 from JWKS (old tokens with that kid stop verifying)

Cache-aside pattern in Rust

Fetching the JWKS endpoint on every request would add ~50-200ms of latency. The standard pattern is cache-aside with lazy refresh:

pub struct JwksCache {
    jwks_url: String,
    // moka is a concurrent cache with TTL eviction (Rust equivalent of Caffeine/Guava)
    cache: Cache<String, DecodingKey>,
    http: reqwest::Client,
}
 
impl JwksCache {
    pub async fn get_key(&self, kid: &str) -> Result<DecodingKey, Error> {
        // 1. Cache hit → return immediately (hot path, no I/O)
        if let Some(key) = self.cache.get(kid) {
            return Ok(key);
        }
 
        // 2. Cache miss → fetch ALL keys from JWKS endpoint
        self.refresh().await?;
 
        // 3. Check again → key should now be cached
        self.cache.get(kid).ok_or(Error::UnknownKid)
    }
 
    async fn refresh(&self) -> Result<(), Error> {
        let jwks: JwksResponse = self.http.get(&self.jwks_url)
            .send().await?.json().await?;
 
        for key in jwks.keys {
            let decoding_key = DecodingKey::from_rsa_components(&key.n, &key.e)?;
            self.cache.insert(key.kid, decoding_key);
        }
        Ok(())
    }
}

The double-check pattern (check → refresh → check) handles key rotation gracefully: if the provider adds a new key, the first request with that kid triggers a refresh that discovers it. No manual intervention, no redeployment.

TTL tradeoff

Cache TTL (typically 1 hour) means a revoked key stays trusted until the cache entry expires. This is the universal tradeoff: shorter TTL = more JWKS fetches but faster revocation. For most applications, 1 hour is acceptable — token exp claims provide the primary revocation mechanism.

Multi-Tenant JWT Claims

Auth providers like Descope and Auth0 Organizations support embedding tenant membership directly in the JWT:

{
  "sub": "user-abc-123",
  "email": "alice@example.com",
  "tenants": {
    "tenant-uuid-1": { "roles": ["admin"] },
    "tenant-uuid-2": { "roles": ["viewer"] }
  }
}

The tenants map is cryptographically signed (part of the JWT payload). It represents ALL tenants this user belongs to at the time the token was issued.

The tenant selection pattern

A user with multiple tenants needs to tell the server which one to act as for THIS request. The pattern:

  • X-Tenant-ID header (untrusted, user-controlled): “I want to act as tenant-uuid-1”
  • JWT tenants map (trusted, cryptographically signed): “This user belongs to [tenant-uuid-1, tenant-uuid-2]”
  • Server-side verification: check that the header value exists as a key in the JWT map
// Step 1: Extract from JWT (trusted)
let tenants = claims.tenants.ok_or(TenantError::InvalidTenantId)?;
 
// Step 2: Read header (untrusted)
let tenant_id = headers.get("X-Tenant-ID")
    .and_then(|v| Uuid::parse_str(v.to_str().ok()?).ok())
    .ok_or(TenantError::MissingHeader)?;
 
// Step 3: Verify untrusted against trusted
let tenant_claim = tenants.get(&tenant_id.to_string())
    .ok_or(TenantError::InvalidTenantId)?;

Without step 3, a user could set X-Tenant-ID: <any-uuid> and access any tenant’s data.

Why the header is needed at all

The JWT carries ALL tenant memberships, but the server needs to know which one the user wants RIGHT NOW. Without the header, the server would have to guess (use the first one? require a separate “switch tenant” API?). The header is a selection mechanism — the JWT is the authorization proof.

Tradeoff: JWT-embedded vs DB-lookup

JWT-embedded tenants (Descope model): zero DB queries for authorization, but membership is stale until the user gets a new token (login or refresh). Adding a user to a new org requires them to re-authenticate.

DB-lookup tenants (commercial model): SELECT 1 FROM memberships WHERE user_id = $1 AND tenant_id = $2 on every request. Real-time accuracy — remove a user and they’re instantly locked out. Costs one DB query per request (cacheable with short TTL).

Hybrid (production-grade): JWT for the fast path, DB check for security-critical operations, background revocation when memberships change.

See also