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. 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:

FieldDefaultMeaning
prefix/api/authURL prefix of the four endpoints
access_ttl_secs900access token lifetime
refresh_ttl_secs7 daysrefresh token lifetime
refresh_absolute_ttl_secs30 dayslongest a login lasts across refreshes; must be > 0
refresh_reuse_grace_secs10retry window after a rotation; 0 treats every reuse as theft
session_secretNoneexplicit signing key instead of RUSTANGO_SESSION_SECRET
jti_storeNonerevocation store; None is the in-memory one
extra_claimsNonehook 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, /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), 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; 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.

    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 JwtLifecycle and JwtBackend alike.


See also