JWT auth API
The standalone JWT module signs and verifies one token. A real
API needs the whole lifecycle: a short-lived access token, a long-lived
refresh token, rotation on refresh, and revocation for logout. Rustango
ships that as JwtLifecycle — and a batteries-included router that mounts
POST /api/auth/login, /refresh, /logout, and GET /me for you.
Source:
rustango::tenancy::jwt_lifecycle(JwtLifecycle,JwtTokenPair,JwtClaims) andrustango::tenancy::auth_routes(JwtAuth,Config) +rustango::jti_store(JtiStore,InMemoryJtiStore) — behindjwt+tenancy.Runnable version: the token engine is covered by the tested
auth_demo—cargo test -p auth_demo --test auth_jwt_api. The HTTP endpoints are tenant-scoped and exercised end-to-end by the framework's owncrates/rustango/tests/tenant_auth_live.rs.
New to a term here? access/refresh token, rotation, revocation — see the glossary.
Deep dive companion to the Security guide's "Issuing and refreshing JWTs" section. For a single, manually-managed token instead, see JWT (standalone).
Table of contents
- The built-in router · Wiring it up
- The token engine · Refresh & rotation
- Revocation & the JTI store · Custom claims
- Notes & limits
The built-in router
JwtAuth::router() mounts the standard four endpoints against the per-tenant
rustango_users table — the ~50 lines of login boilerplate every project
otherwise rewrites:
| Method | Path | Body / Auth | Returns |
|---|---|---|---|
| POST | /api/auth/login | {username, password} | {access, refresh, user} |
| POST | /api/auth/refresh | {refresh} | {access, refresh} |
| POST | /api/auth/logout | Authorization: Bearer <access> + optional {refresh} | 204 (revokes both JTIs) |
| GET | /api/auth/me | Authorization: Bearer <access> | {user_id, username, is_superuser} |
Login verifies the password with argon2id, then issues a
pair. A wrong password, an unknown user and an inactive account all get the
same 401 invalid credentials, so the endpoint cannot list accounts. Logins
share the rate limits and account lock of the tenant admin login: over the
limit the answer is 429, and 503 when password hashing is busy. /me
answers 403 for an inactive account. Paths, TTLs, and the signing key are
configurable via Config.
Wiring it up
use axum::middleware::from_fn_with_state;
use rustango::tenancy::auth_routes::{require_bearer, Config, JwtAuth};
let auth = JwtAuth::new(Config::default());
rustango::manage::Cli::new()
.tenancy()
.api(my_app::urls::api()
.layer(from_fn_with_state(auth.clone(), require_bearer)) // your routes
.merge(auth.router())) // /api/auth/*
.run()
.await
Build one JwtAuth and pass clones of it everywhere. Each instance has its
own in-memory revocation list, so with two, a logout on the router is not seen
by require_bearer and the token keeps working until it expires. A shared
jti_store (Redis, database) removes that split too.
require_bearer and /me accept only tokens minted by /login or /refresh,
and refuse them once the user logs out anywhere or changes password.
Config::default() signs with RUSTANGO_SESSION_SECRET (the same key as the
admin session cookie, base64-decoded) and uses 15-min access / 7-day refresh
TTLs. JwtAuth::new panics when the key is under 32 bytes, so a misconfigured
server refuses to start. The Config fields:
| Field | Default | Meaning |
|---|---|---|
prefix | /api/auth | URL prefix of the four endpoints |
access_ttl_secs | 900 | access token lifetime |
refresh_ttl_secs | 7 days | refresh token lifetime |
refresh_absolute_ttl_secs | 30 days | longest a login lasts across refreshes; must be > 0 |
refresh_reuse_grace_secs | 10 | retry window after a rotation; 0 treats every reuse as theft |
session_secret | None | explicit signing key instead of RUSTANGO_SESSION_SECRET |
jti_store | None | revocation store; None is the in-memory one |
extra_claims | None | hook that adds claims to both tokens at login and on every refresh |
extra_claims gets a ClaimsContext (user_id, username, is_superuser,
tenant_slug). Returning a reserved name (sub, exp, jti, typ, kind)
fails the login with 500; tenant, pwf, sat and fam are the router's
own. With the config feature, Config::with_jwt_settings(&settings.auth.jwt)
reads access_ttl_secs and refresh_ttl_secs from [auth.jwt]; the
issuer and audience keys there have no effect yet.
The endpoints run under the tenant context, so mount them in a tenancy app.
For a tenant database type other than the default, use router_for::<DB>()
and require_bearer_for::<DB>. auth.lifecycle() gives the JwtLifecycle
behind it, and auth.verify_for_tenant(bearer, &tenant) runs the same checks
as require_bearer and returns the user id.
# Login → access + refresh
curl -sX POST localhost:8080/api/auth/login \
-H 'content-type: application/json' \
-d '{"username":"alice","password":"hunter2hunter"}'
# Call a protected endpoint
curl localhost:8080/api/auth/me -H "Authorization: Bearer $ACCESS"
The token engine (JwtLifecycle)
Under the router sits JwtLifecycle — usable directly if you want the lifecycle
without the built-in HTTP shape:
use rustango::tenancy::jwt_lifecycle::JwtLifecycle;
let jwt = JwtLifecycle::new(secret_32_bytes);
// Login: issue the pair.
let pair = jwt.issue_pair(user_id);
// → pair.access (short TTL, send in the Authorization header)
// → pair.refresh (long TTL, store in an HttpOnly cookie / secure storage)
// Authenticated request: verify the access token.
match jwt.verify_access(&access).await {
Some(claims) => { /* claims.sub is the user id */ }
None => { /* 401: invalid, expired, revoked, or wrong type */ }
}
JwtLifecycle::new takes the key as a Vec<u8> and panics when it is under
32 bytes. The TTLs default to 15 min / 7 days; change them with
.with_access_ttl(secs) and .with_refresh_ttl(secs).
issue_access_with(user_id, custom) mints a lone access token with no refresh
half, and blacklist_size() reports roughly how many JTIs are revoked (0
for a store that cannot count).
Access and refresh tokens are not interchangeable — verify_access rejects
a refresh token and vice versa, so a stolen short-lived access token can't be
used to mint new ones:
let pair = jwt.issue_pair(42);
assert!(jwt.verify_refresh(&pair.access).await.is_none());
assert!(jwt.verify_access(&pair.refresh).await.is_none());
Refresh and rotation
refresh exchanges a valid refresh token for a new pair and blacklists the
old refresh token's JTI — sliding expiry with single-use refresh tokens (replay
of the old one is rejected):
let pair = jwt.issue_pair(7);
let rotated = jwt.refresh(&pair.refresh).await.expect("refresh ok");
assert_ne!(pair.access, rotated.access);
assert!(jwt.refresh(&pair.refresh).await.is_none()); // old refresh is now dead
By default refresh preserves the token's custom claims. If permissions may
have changed (role revoked, scope downgraded), use refresh_with(token, new_claims)
to substitute a fresh payload while still blacklisting the old refresh JTI.
POST /api/auth/refresh adds three checks: a password change since login ends the
chain, no chain outlives Config::refresh_absolute_ttl_secs (30 days) from login, and
replaying an already-rotated token revokes the whole chain. A retry within about
Config::refresh_reuse_grace_secs (10–20 s) of the rotation only gets a 401, so an
honest client that sent two refreshes stays logged in; the cost is that a thief who
replays inside that window does not trigger the revoke. Revocation is only as strong
as the JTI store (an in-memory one forgets on restart).
Revocation and the JTI store
Each token carries a unique jti. revoke adds it to a blacklist so subsequent
verify_* calls fail until the token would have expired anyway — this is what
POST /api/auth/logout calls. It checks only the signature, so it also takes an
expired token, and it returns false for a token it cannot verify:
let pair = jwt.issue_pair(1);
assert!(jwt.revoke(&pair.access).await);
assert!(jwt.verify_access(&pair.access).await.is_none());
Send the refresh token to /logout
Revoking the bearer alone ends a token that would have expired in minutes anyway. The refresh token is the one with days of life, and it can mint fresh access tokens for its whole TTL — so a logout that leaves it alive doesn't end the session, it postpones it:
POST /api/auth/logout
Authorization: Bearer <access>
{ "refresh": "<refresh>" } // revokes the long-lived half too
The body is optional, so clients written against the older endpoint keep working unchanged — they simply revoke less. Send it. Both halves are pinned to the calling tenant, so one subdomain cannot revoke another's token.
The blacklist lives in a pluggable JtiStore. The default InMemoryJtiStore is
single-process and loses revocations on restart — fine for one instance. Any
multi-replica deployment MUST install a shared, durable store (Redis / DB) so a
logout on one replica is honored by all:
use rustango::jti_store::{InMemoryJtiStore, JtiStore};
use std::sync::Arc;
let shared: Arc<dyn JtiStore> = Arc::new(InMemoryJtiStore::new()); // swap for Redis in prod
let a = JwtLifecycle::new(secret.clone()).with_jti_store(Arc::clone(&shared));
let b = JwtLifecycle::new(secret).with_jti_store(Arc::clone(&shared));
let pair = a.issue_pair(5);
a.revoke(&pair.access).await;
assert!(b.verify_access(&pair.access).await.is_none()); // B sees A's revocation
Without a shared store,
/logoutis best-effort: a revoked token may still be accepted on another replica until its natural expiry. This is the single most important production setting for JWT auth.
Writing a durable store
JtiStore is async (since v0.52), so a durable implementation is one
query — no background flusher, no convergence window during which a revoked
jti is still accepted elsewhere. Both methods return
JtiFuture<'_, T> (a boxed future — the trait is used as Arc<dyn JtiStore>,
and native async fn in traits is not dyn-compatible):
use rustango::jti_store::{JtiFuture, JtiStore};
impl JtiStore for PgJtiStore {
fn is_used<'a>(&'a self, jti: &'a str) -> JtiFuture<'a, bool> {
Box::pin(async move { self.lookup(jti).await })
}
fn mark_used<'a>(&'a self, jti: &'a str, exp_unix: i64) -> JtiFuture<'a, bool> {
Box::pin(async move {
// One conditional write — atomic, and immediately visible to every
// replica. `INSERT … ON CONFLICT DO NOTHING` (or Redis `SET NX`);
// never a read followed by a write.
self.insert_if_absent(jti, exp_unix).await
})
}
}
mark_used MUST be atomic: concurrent callers for the same jti must see
exactly one true, or the single-use refresh guarantee is gone.
Two more methods have defaults: approx_size (None, used by
blacklist_size) and is_process_local (false; return true only for a
store that does not share revocations, which makes JwtAuth::new log a
warning).
Because verification consults the store, verify_access, verify_refresh,
refresh, revoke and the MCP/tenant token helpers
(mcp::verify_agent_token, tenancy::auth_routes::JwtAuth::verify_for_tenant) are all
async. Token expiry is checked before the store is consulted, so an
expired token never costs a round trip.
Custom claims
Embed roles / tenant / scope directly in the token so verification needs no
DB lookup. Reserved names (sub, exp, jti, typ) are rejected:
let custom = serde_json::json!({ "roles": ["admin"], "tenant": "acme" })
.as_object().unwrap().clone();
let pair = jwt.issue_pair_with(99, custom)?;
let claims = jwt.verify_access(&pair.access).await.unwrap();
let roles: Vec<String> = claims.get_custom("roles").unwrap(); // ["admin"]
Custom claims survive refresh (carried onto the new pair) unless you use
refresh_with.
Notes and limits
-
Sessions vs JWT vs this: a plain JWT can't be revoked; a Session is revocable but needs a per-request store lookup;
JwtLifecycleis the middle path — stateless verify, plus a JTI blocklist for the revocations you actually need (logout, rotation). -
HTTP endpoints are tenant-scoped.
JwtAuth::router()resolves users via the tenant context +rustango_users; mount it in a.tenancy()app. The token engine (JwtLifecycle) itself has no such requirement. -
Pair this with the auth backend chain's
JwtBackendto authenticate arbitrary routes from theAuthorization: Bearerheader. -
HS256 signing, 32-byte key floor — same algorithm and constraints as standalone JWT.
-
The tokens are ordinary JWTs, so anything that verifies a JWT can verify these:
jwt.io, your platform's standard library, an API gateway, another service you hand a token to. Three segments, a JOSE header of{"alg":"HS256","typ":"JWT"}, signed overheader.payload.Before 0.57.5 they were two segments with no header, signed over the payload alone — readable by nothing but rustango, and rejected even by
rustango::jwt::decode. If you wrote a custom verifier to work around that, you can drop it.Since 0.62.0 a two-segment token is refused, by
JwtLifecycleandJwtBackendalike.
