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:
-
Claimsis aFromRequestPartsextractor, so it runs in the extractor phase and only reads headers. A missing or invalid token is rejected with401before 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 get404(not401), and global layers added withRouter::layer, such asTraceLayer,CorsLayeror 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
tokenis 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
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();
}
Source: jsonwebtoken 11.1.0 and axum example: jwt
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 saysnoneorRS256is rejected. Never take the algorithm from the token header. -
Expiry.
expis required by default and checked withleewayseconds of tolerance for clock skew;nbfis checked ifvalidate_nbfis set. -
Issuer and audience.
set_issuerandset_audiencereject 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_claimsturns missing claims into errors instead of silently skipping the check. -
Errors are
jsonwebtoken::errors::Errorwith akind()(ExpiredSignature,InvalidAudience,InvalidSignature, …). Log the kind atdebug, but answer every failure with the same401and an RFC 6750WWW-Authenticatechallenge.
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 theirexp, 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 |
Every token is rejected with |
|
Every token is rejected with |
The |
Tokens fail for a few seconds after issue on another host |
Clock skew between issuer and verifier. Keep a small |
The extractor works in a handler but |
The state passed to the layer does not satisfy |
A request with |
|
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.