JWT & Bearer Tokens

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against jsonwebtoken 11.1.0 with axum 0.8.9 and axum-extra 0.12.6 on Rust 1.99 (edition 2024), checked on 2026-10-06. The Bookshelf JSON API authenticates clients with bearer tokens; the web UI uses sessions (see Sessions & Cookies). JWT structure, signing algorithms and token lifetimes are explained on this site’s JWT and JOSE and Access and Refresh Tokens pages; this page shows the Rust side.

Where it runs in the request

In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, extractors run, the handler runs, the response travels back out), token checking sits after routing and before the handler:

  • Claims is a FromRequestParts extractor, so it runs in the extractor phase and only reads headers. A missing or invalid token is rejected with 401 before any body extractor (Json) buffers the request body.

  • route_layer(from_extractor_with_state::<Claims, _>(..)) runs the same extractor as middleware on the matched routes only. Unmatched paths still get 404 (not 401), and global layers added with Router::layer, such as TraceLayer, CorsLayer or a rate limiter, run before it — so CORS preflights and rejected requests are still traced and limited.

  • Issuing tokens is ordinary handler work. The Argon2 verification inside token is the expensive part, which is why it runs on tokio’s blocking pool and why the token endpoint belongs behind a rate limiter.

jsonwebtoken 11 needs a crypto provider

Since version 10, jsonwebtoken delegates cryptography to a provider, chosen with exactly one Cargo feature: aws_lc_rs (AWS-LC, fast, needs a C compiler and CMake to build) or rust_crypto (the pure-Rust RustCrypto crates). The provider is resolved lazily, on the first encode or decode: with neither feature enabled (or with both), and no provider installed by hand, that first call panics with "Could not automatically determine the process-level CryptoProvider from jsonwebtoken crate features". The program compiles fine, so the panic surfaces in the first request that issues or checks a token — run one token round-trip in a start-up smoke test.

[dependencies]
jsonwebtoken = { version = "11.1.0", features = ["aws_lc_rs"] } # or features = ["rust_crypto"]

The alternative to the provider features is to choose the provider in code. jsonwebtoken::crypto::CryptoProvider has an install_default() method, modelled on rustls, that sets the process-wide provider; it succeeds at most once per process and must run before the first token is signed or verified. The built-in providers are the DEFAULT_PROVIDER statics in jsonwebtoken::crypto::aws_lc and jsonwebtoken::crypto::rust_crypto (each behind its feature), and a custom CryptoProvider (for example one backed by a hardware security module) can be installed the same way. Installing explicitly also settles the ambiguity when two dependencies enable different provider features:

use jsonwebtoken::crypto::aws_lc;

fn main() {
    // first thing in main, before any encode/decode; Err means another provider was installed first
    aws_lc::DEFAULT_PROVIDER.install_default().expect("jsonwebtoken crypto provider already installed");
}

Version 11 (July 2026) also removed Validation::insecure_disable_signature_validation (use the dangerous module), made Algorithm non-exhaustive and turned optional dependencies into explicit features.

Issuing and verifying tokens

sequenceDiagram participant C as API client participant A as POST /api/v1/auth/token participant R as POST /api/v1/auth/refresh participant B as GET /api/v1/me/loans C->>A: username + password A-->>C: access token (JWT, 15 min) + refresh token (opaque, 30 days) C->>B: Authorization: Bearer <access token> B-->>C: 200 loans Note over C,B: 15 minutes later C->>B: Authorization: Bearer <expired token> B-->>C: 401 + WWW-Authenticate: Bearer error="invalid_token" C->>R: refresh token R-->>C: new access token + NEW refresh token (old one revoked) C->>B: Authorization: Bearer <new access token> B-->>C: 200 loans
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

use argon2::{
    Argon2,
    password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
};
use axum::{
    Json, Router,
    extract::{FromRef, FromRequestParts, State},
    http::{StatusCode, header, request::Parts},
    middleware::from_extractor_with_state,
    response::{IntoResponse, Response},
    routing::{get, post},
};
use axum_extra::{
    TypedHeader,
    headers::{Authorization, authorization::Bearer},
};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};

const ISSUER: &str = "https://bookshelf.example";
const AUDIENCE: &str = "bookshelf-api";
const ACCESS_TTL_SECS: u64 = 15 * 60;

#[derive(Debug, Serialize, Deserialize, Clone)]
struct Claims {
    sub: String,  // user id
    iss: String,
    aud: String,
    iat: u64,
    exp: u64,
    roles: Vec<String>,
}

/// Signing keys, loaded once at start-up and shared through state (not a global).
#[derive(Clone)]
struct Keys {
    encoding: EncodingKey,
    decoding: DecodingKey,
}

impl Keys {
    fn from_secret(secret: &[u8]) -> Self {
        Self { encoding: EncodingKey::from_secret(secret), decoding: DecodingKey::from_secret(secret) }
    }
}

/// A row of the `users` table: the password is only ever stored as an Argon2id PHC string.
struct StoredUser {
    id: String,
    password_hash: String,
}

#[derive(Clone, FromRef)]
struct AppState {
    keys: Keys,
    users: Arc<HashMap<String, StoredUser>>, // username -> user (a `users` table in production)
    refresh_tokens: Arc<Mutex<HashMap<String, String>>>, // refresh token -> user id (a table in production)
}

fn now() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).expect("clock after 1970").as_secs()
}

// ---------- the Claims extractor ----------
enum AuthError {
    Missing,
    Invalid,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let challenge = match self {
            AuthError::Missing => r#"Bearer realm="bookshelf""#,
            AuthError::Invalid => r#"Bearer realm="bookshelf", error="invalid_token""#,
        };
        (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, challenge)]).into_response()
    }
}

impl<S> FromRequestParts<S> for Claims
where
    S: Send + Sync,
    Keys: FromRef<S>,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        // TypedHeader<Authorization<Bearer>> rejects anything that is not "Bearer <token>"
        let TypedHeader(Authorization(bearer)) =
            TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await.map_err(|_| AuthError::Missing)?;

        let mut validation = Validation::new(Algorithm::HS256); // only this algorithm is accepted
        validation.set_issuer(&[ISSUER]);
        validation.set_audience(&[AUDIENCE]);
        validation.leeway = 30; // seconds of clock skew tolerated for exp/nbf
        validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);

        let keys = Keys::from_ref(state);
        decode::<Claims>(bearer.token(), &keys.decoding, &validation).map(|data| data.claims).map_err(|err| {
            tracing::debug!(%err, "rejected token");
            AuthError::Invalid
        })
    }
}

// ---------- issuing ----------
#[derive(Deserialize)]
struct TokenRequest {
    username: String,
    password: String,
}

#[derive(Serialize)]
struct TokenResponse {
    access_token: String,
    token_type: &'static str,
    expires_in: u64,
    refresh_token: String,
}

fn issue(state: &AppState, user_id: &str) -> Result<TokenResponse, StatusCode> {
    let iat = now();
    let claims = Claims {
        sub: user_id.to_owned(),
        iss: ISSUER.into(),
        aud: AUDIENCE.into(),
        iat,
        exp: iat + ACCESS_TTL_SECS,
        roles: vec!["member".into()],
    };
    let access_token =
        encode(&Header::new(Algorithm::HS256), &claims, &state.keys.encoding).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let refresh_token = uuid::Uuid::new_v4().simple().to_string(); // opaque, random, stored server-side
    state.refresh_tokens.lock().expect("lock").insert(refresh_token.clone(), user_id.to_owned());
    Ok(TokenResponse { access_token, token_type: "Bearer", expires_in: ACCESS_TTL_SECS, refresh_token })
}

/// Verified against when the username is unknown, so both failures cost one Argon2 verification.
static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| {
    Argon2::default().hash_password(b"dummy password for timing").expect("hash").to_string()
});

/// The same check as on the Password Authentication page, off the async executor.
async fn verify_password(password: String, phc: String) -> Result<bool, StatusCode> {
    tokio::task::spawn_blocking(move || {
        PasswordHash::new(&phc)
            .is_ok_and(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
    })
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

async fn token(State(state): State<AppState>, Json(req): Json<TokenRequest>) -> Result<Json<TokenResponse>, StatusCode> {
    let user = state.users.get(&req.username);
    let stored = user.map_or_else(|| DUMMY_HASH.clone(), |u| u.password_hash.clone());
    let valid = verify_password(req.password, stored).await?;
    match user {
        Some(user) if valid => issue(&state, &user.id).map(Json),
        _ => Err(StatusCode::UNAUTHORIZED), // one answer for "no such user" and "wrong password"
    }
}

#[derive(Deserialize)]
struct RefreshRequest {
    refresh_token: String,
}

async fn refresh(State(state): State<AppState>, Json(req): Json<RefreshRequest>) -> Result<Json<TokenResponse>, StatusCode> {
    // rotation: a refresh token can be used once; reuse of a revoked one should revoke the whole family
    let user_id = state.refresh_tokens.lock().expect("lock").remove(&req.refresh_token).ok_or(StatusCode::UNAUTHORIZED)?;
    issue(&state, &user_id).map(Json)
}

async fn my_loans(claims: Claims) -> String {
    format!("loans of user {} (roles {:?})\n", claims.sub, claims.roles)
}

#[tokio::main]
async fn main() {
    // e.g. JWT_SECRET=$(openssl rand -base64 48); no fallback: a missing secret stops the process here
    let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    assert!(secret.len() >= 32, "JWT_SECRET must be at least 32 bytes (256 bits) for HS256");

    // demo user table: production loads `password_hash` from the database instead
    let demo_password = std::env::var("DEMO_PASSWORD").expect("DEMO_PASSWORD must be set");
    let demo_hash = Argon2::default().hash_password(demo_password.as_bytes()).expect("hash").to_string();
    let users = HashMap::from([("ursula".to_owned(), StoredUser { id: "42".into(), password_hash: demo_hash })]);

    let state = AppState {
        keys: Keys::from_secret(secret.as_bytes()),
        users: Arc::new(users),
        refresh_tokens: Arc::default(),
    };

    let protected = Router::new()
        .route("/api/v1/me/loans", get(my_loans))
        .route_layer(from_extractor_with_state::<Claims, _>(state.clone())); // every route here needs a valid token

    let app = Router::new()
        .route("/api/v1/auth/token", post(token))
        .route("/api/v1/auth/refresh", post(refresh))
        .merge(protected)
        .with_state(state);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The token endpoint verifies the password exactly as the Password Authentication page does: it looks up the stored Argon2id PHC string, parses it with PasswordHash::new and calls Argon2::default().verify_password on the blocking pool, and it verifies against a dummy hash when the username is unknown, so both failures take the same time and return the same 401. The demo builds its one-row user table at start-up from a DEMO_PASSWORD variable; a real deployment reads password_hash from the users table and never sees a plain-text password outside the request body. Both JWT_SECRET and DEMO_PASSWORD are required: a missing variable stops the process at start-up instead of silently signing tokens with a well-known fallback key.

$ cargo run
thread 'main' (3931) panicked at src/main.rs:185:46:
JWT_SECRET must be set: NotPresent
$ export JWT_SECRET=$(openssl rand -base64 48) DEMO_PASSWORD=$(openssl rand -base64 18)
$ cargo run &
$ login() { jq -n --arg u "$1" --arg p "$2" '{username: $u, password: $p}' |
    curl -s -H 'content-type: application/json' -d @- "${@:3}" localhost:3000/api/v1/auth/token; }
$ TOKENS=$(login ursula "$DEMO_PASSWORD")
$ ACCESS=$(echo "$TOKENS" | jq -r .access_token)
$ curl -s -H "authorization: Bearer $ACCESS" localhost:3000/api/v1/me/loans
loans of user 42 (roles ["member"])
$ login ursula "x$DEMO_PASSWORD" -o /dev/null -w '%{http_code}\n'   # wrong password
401
$ login nobody "$DEMO_PASSWORD" -o /dev/null -w '%{http_code}\n'     # unknown user
401
$ curl -si -H "authorization: $ACCESS" localhost:3000/api/v1/me/loans | grep -i -E '^HTTP|www-authenticate'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="bookshelf"
$ REFRESH=$(echo "$TOKENS" | jq -r .refresh_token)
$ curl -s -H 'content-type: application/json' -d "{\"refresh_token\":\"$REFRESH\"}" localhost:3000/api/v1/auth/refresh | jq -r .token_type
Bearer
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'content-type: application/json' -d "{\"refresh_token\":\"$REFRESH\"}" localhost:3000/api/v1/auth/refresh
401

What the validation checks

  • Algorithm pinning. Validation::new(Algorithm::HS256) accepts only HS256 tokens; a token whose header says none or RS256 is rejected. Never take the algorithm from the token header.

  • Expiry. exp is required by default and checked with leeway seconds of tolerance for clock skew; nbf is checked if validate_nbf is set.

  • Issuer and audience. set_issuer and set_audience reject tokens minted for another service, which matters as soon as several services share a signing key or an identity provider.

  • Required claims. set_required_spec_claims turns missing claims into errors instead of silently skipping the check.

  • Errors are jsonwebtoken::errors::Error with a kind() (ExpiredSignature, InvalidAudience, InvalidSignature, …​). Log the kind at debug, but answer every failure with the same 401 and an RFC 6750 WWW-Authenticate challenge.

Keys from the environment, not a global

The secret is read once in main and placed in state, from where the extractor gets it through FromRef. Tests can build a state with a test key; there is no hidden global to reset. HMAC secrets must be long and random — at least 256 bits for HS256 (openssl rand -base64 48) — and come from a secret store or the environment, never from source code or the container image (see Configuration).

For tokens verified by other services, use an asymmetric algorithm: EdDSA (Ed25519) or RS256/ES256. The issuer signs with the private key (EncodingKey::from_ed_pem) and verifiers only hold the public key (DecodingKey::from_ed_pem), so a compromised verifier cannot mint tokens.

Lifetimes, refresh tokens and revocation

A JWT is valid until it expires; the server keeps no record it could delete. Keep access tokens short-lived (5-15 minutes) and pair them with refresh tokens that are opaque random strings stored server-side, so they can be revoked:

  • Rotation: each refresh returns a new refresh token and invalidates the old one, as the example does. If a revoked refresh token is presented again, it was stolen: revoke every token of that user’s "family".

  • Logout deletes the refresh token; the access token dies within minutes. For immediate revocation (a compromised account), keep a short deny-list of token ids (jti) until their exp, checked in the extractor — at the cost of a lookup per request.

  • Never put secrets or personal data in claims: a JWT is signed, not encrypted, and anyone can read it.

Key rotation with kid and JWKS

Rotating a signing key without logging everyone out requires accepting two keys for a while. Put a key id in the header (Header { kid: Some("2026-10".into()), ..Header::new(Algorithm::EdDSA) }), keep the previous public key, and choose the DecodingKey by the token’s kid (read with jsonwebtoken::decode_header before verification). When tokens come from an identity provider, fetch its JSON Web Key Set (JWKS) from the jwks_uri in its discovery document, cache it, and build DecodingKeys with DecodingKey::from_jwk; refresh the cache when an unknown kid appears. The OAuth2 & OpenID Connect page validates provider-issued ID tokens.

The dangerous module

jsonwebtoken::dangerous::insecure_decode (10.1) and insecure_decode_claims (11.1) read a token without verifying its signature. They exist for debugging and for routing decisions made before verification (such as picking a tenant’s key), and their name is a warning: never authorise anything based on their output.

Common errors and pitfalls

Symptom Cause and fix

Panic "Could not automatically determine the process-level CryptoProvider" on the first request

No provider feature (or both) is enabled. Enable exactly one of aws_lc_rs and rust_crypto, or call install_default() at start-up.

Every token is rejected with InvalidAudience

validate_aud is on by default, and a token that carries an aud claim is rejected when the Validation lists no audience. Call set_audience with the value the issuer puts in the token; turn validate_aud off only if the audience is checked some other way.

Every token is rejected with InvalidAlgorithm

The Validation algorithm list does not contain the token’s alg, for example a provider signing with RS256 while the code builds Validation::new(Algorithm::HS256). Pin the algorithm the issuer actually uses.

Tokens fail for a few seconds after issue on another host

Clock skew between issuer and verifier. Keep a small leeway (30—​60 seconds) and run NTP; do not raise it to minutes.

The extractor works in a handler but from_extractor_with_state does not compile

The state passed to the layer does not satisfy Keys: FromRef<S>. Pass the same AppState as with_state, and derive FromRef on it.

A request with Authorization: Token abc gets 401 with no error=

TypedHeader<Authorization<Bearer>> only accepts the Bearer scheme (case-insensitive). This is intended: RFC 6750 defines no other scheme.

Where the books are outdated

Yoon (Beginning Axum, chapter 4) uses jsonwebtoken 9, keeps the secret in a lazy_static! global and accepts the Authorization header with or without the Bearer ` prefix. With jsonwebtoken 11 a crypto provider is mandatory (a feature or `install_default()); keys belong in state (or, if a global is really needed, std::sync::LazyLock since Rust 1.80, not lazy_static!); and the extractor must require the Bearer scheme defined by RFC 6750. Crossley et al. (Rust for Scientific Computing, ch. 15) protects an API with a hard-coded token over plain HTTP; tokens must be random, stored securely and sent only over TLS.