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
Header
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 atiss— issueraud— 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 keyfrom_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/nbfValidation::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 pastexpInvalidToken— malformedInvalidSignature— signature doesn’t matchInvalidAlgorithm— header says one algorithm, validation expects another
JWT in Axum middleware
The typical pattern for HTTP API authentication:
- Client sends
Authorization: Bearer <token>header - Middleware extracts and decodes the token
- On success, inserts a
CurrentUserinto request extensions - Handlers access it via
Extension<CurrentUser> - On failure, returns
401 Unauthorizedbefore 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:
- Read the
kidfrom the JWT header (not the payload — the header is unverified metadata) - Look up that key in the JWKS
- Verify the JWT’s signature using that specific key
Key rotation transparency
The provider can rotate keys without coordinating with consumers:
- Provider adds
key-3to the JWKS endpoint - Provider starts signing new JWTs with
key-3 - Old JWTs signed with
key-1orkey-2still verify (those keys remain in JWKS) - Eventually provider removes
key-1from JWKS (old tokens with thatkidstop 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
expclaims 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-IDheader (untrusted, user-controlled): “I want to act as tenant-uuid-1”- JWT
tenantsmap (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 = $2on 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
- Asymmetric Elliptic Curve Cryptography — the math behind ES256
- Symmetric encryption — the basis for HMAC-based JWT signing
- Multi-Tenant Authentication Patterns — full tenant lifecycle and management
- Cedar Authorization — how tenant identity feeds into policy evaluation