Rustango docs
← Authentication

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.

JWT auth API: login issues an access+refresh pair, refresh rotates and blacklists the old token, logout revokes via a JTI store

Source: rustango::tenancy::jwt_lifecycle (JwtLifecycle, JwtTokenPair, JwtClaims) and rustango::tenancy::auth_routes (JwtAuth, Config) + rustango::jti_store (JtiStore, InMemoryJtiStore) — behind jwt + 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 own crates/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

JwtAuth::router() mounts the standard four endpoints against the per-tenant rustango_users table — the ~50 lines of login boilerplate every project otherwise rewrites:

MethodPathBody / AuthReturns
POST/api/auth/login{username, password}{access, refresh, user}
POST/api/auth/refresh{refresh}{access, refresh}
POST/api/auth/logoutAuthorization: Bearer <access> + optional {refresh}204 (revokes both JTIs)
GET/api/auth/meAuthorization: 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, /logout is 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; JwtLifecycle is 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 JwtBackend to authenticate arbitrary routes from the Authorization: Bearer header.

  • 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 over header.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.


See also