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. 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) and uses 15-min access / 7-day refresh TTLs. Override
prefix, access_ttl_secs, refresh_ttl_secs, or session_secret as needed.
The endpoints run under the tenant context, so mount them in a tenancy app.
# 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 */ }
}
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:
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, #1191), 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.
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.Until #1397 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.Tokens minted before the fix are still accepted on verify so an upgrade does not log anyone out. That compatibility path is removed in 0.58, by which point any token in the old shape has long expired.
