Rustango docs
← Authentication

Auth backends

An auth backend answers one question: given an incoming request, who is the user? Rustango lets you stack several — HTTP Basic, API key, JWT — into a chain that the auth middleware tries in order, so one app can accept humans and machines on the same routes. You pick which backends to register and in what order, and the chain is wired to axum. Pair it with require_auth / require_perm to gate routes and the CurrentUser extractor to read the result.

Auth backends in Rustango: a request flows through a chain of backends (ModelBackend, ApiKeyBackend, JwtBackend); the first to recognise the credential wins and injects CurrentUser, then require_perm checks a codename

New to a term here? Backend, middleware, extractor, permission codename — see the glossary.

Source: rustango::tenancy::auth_backends (AuthBackend, ModelBackend, ApiKeyBackend, JwtBackend, AuthUser, AuthError) and rustango::tenancy::{RouterAuthExt, CurrentUser} — behind the tenancy feature. A portable, DB-agnostic registry also lives at rustango::auth_backends, which needs the internal _async_trait feature — pulled in by cache, email, jobs and oauth2, so most builds have it, but a minimal one does not.

Runnable version: every snippet is copied from auth_backends_doc.rs (cargo test -p rustango --features sqlite,tenancy --test auth_backends_doc).

Table of contents


The chain

You hand require_auth a Vec<Arc<dyn AuthBackend>>. On each request the middleware tries them in order:

  • the first backend that recognises the credential wins (returns the user);
  • a backend that doesn't recognise it returns "none" and the next is tried;
  • if a backend hard-errors (e.g. an inactive account on a valid token) the chain stops with that error;
  • if none match, the request is 401 (with require_auth) or proceeds anonymously (with optional_auth).
use std::sync::Arc;
use rustango::tenancy::auth_backends::{ApiKeyBackend, AuthBackend, ModelBackend};

let backends: Vec<Arc<dyn AuthBackend>> = vec![
    Arc::new(ModelBackend),    // HTTP Basic  → humans
    Arc::new(ApiKeyBackend),   // Bearer key  → machines
];

The built-in backends

BackendCredential it readsIdentifies a user by
ModelBackendAuthorization: Basic <base64(user:pass)>username + argon2id password verify against rustango_users
ApiKeyBackendAuthorization: Bearer <prefix.secret>the rustango_api_keys table (see API keys)
JwtBackendAuthorization: Bearer <jwt>a signed HS256 token (see JWT)

ApiKeyBackend and JwtBackend both read Bearer and disambiguate by shape (an API key's first dot-segment is exactly 8 chars). Construct JwtBackend with a secret of at least 32 bytes (JwtBackend::new(secret) panics otherwise):

use rustango::tenancy::auth_backends::JwtBackend;

let backends: Vec<Arc<dyn AuthBackend>> = vec![
    Arc::new(ModelBackend),
    Arc::new(JwtBackend::new(jwt_secret_at_least_32_bytes.to_vec())),
];

On a tenant route the token's tenant claim must match the resolved tenant, and MCP agent tokens (kind claim) are refused. Mint with JwtAuth login or JwtBackend::issue_for_tenant; plain issue tokens work only where no tenant is resolved.

JwtBackend accepts the access tokens JwtLifecycle issues, and refuses its refresh tokens — the two are wire-identical apart from typ, so a refresh token presented as a bearer would otherwise be an access credential with days of life instead of minutes.

Revocation is opt-in and off by default. A bare JwtBackend never consults a blacklist, so a token revoked by /api/auth/logout keeps authenticating through this backend until it expires on its own. Share one store between the lifecycle and the backend to make logout take effect:

use rustango::jti_store::{InMemoryJtiStore, JtiStore};

let shared: Arc<dyn JtiStore> = Arc::new(InMemoryJtiStore::new()); // Redis in prod
let lifecycle = JwtLifecycle::new(secret.clone()).with_jti_store(Arc::clone(&shared));
let backend = JwtBackend::new(secret).with_jti_store(Arc::clone(&shared));

It must be the same store. Wiring two instances looks configured and enforces nothing: logout writes to one and verification reads the other. See revocation and the JTI store.

Write a custom backend by implementing the trait (one async method that inspects the request Parts and returns Option<AuthUser>):

use async_trait::async_trait;   // add `async-trait` to your Cargo.toml
use axum::http::request::Parts;
use rustango::sql::Pool;
use rustango::tenancy::auth_backends::{AuthBackend, AuthError, AuthUser};

struct HeaderBackend;

#[async_trait]
impl AuthBackend for HeaderBackend {
    async fn authenticate(&self, parts: &Parts, _pool: &Pool)
        -> Result<Option<AuthUser>, AuthError>
    {
        // ...inspect parts.headers, return Some(AuthUser{..}) or Ok(None)
        Ok(None)
    }
}

Gating routes: require_auth

RouterAuthExt adds the middleware. require_auth rejects anonymous requests with 401; optional_auth lets them through (so a handler can branch on logged-in vs not):

use rustango::tenancy::RouterAuthExt;

let app = Router::new()
    .route("/profile", get(profile))
    .require_auth(backends);           // 401 if no backend matches

These took a Pool before 0.57.11. They do not any more, and the change is a security fix, not tidying. The pool was captured when the router was built and reused for every request, so a credential issued by one tenant authenticated on another tenant's host — the backends looked the user up in whichever database the router happened to be constructed with, and is_superuser travelled onward in a request extension without a re-query.

The pool now comes from the tenant resolved for each request. Migration is dropping the argument:

.require_auth(backends, pool.clone())  →  .require_auth(backends)
.require_perm("post.add", pool)        →  .require_perm("post.add")

Mount the tenancy layer outside these. Without a tenant context in request extensions they fail closed with a 500 rather than guess a database.

Verified behaviour:

// no credentials               → 401
// Basic alice:<correct>        → 200
// Basic alice:<wrong>          → 401   (no backend accepted; no enumeration)
// Bearer <valid api key>       → 200

Reading the user: CurrentUser

Handlers read the authenticated user with the CurrentUser extractor. It's infallible — Some(user) when a backend resolved one, None otherwise:

use rustango::tenancy::CurrentUser;

async fn profile(CurrentUser(user): CurrentUser) -> Response {
    match user {
        Some(u) => format!("hello {}", u.username).into_response(),
        None    => StatusCode::UNAUTHORIZED.into_response(),
    }
}

Foot-gun: because CurrentUser is infallible, forgetting require_auth doesn't fail to compile — every request just sees None. Behind require_auth, anonymous requests are already 401'd, so user is always Some there.


Permissions: require_perm

require_perm gates a route on a permission codename ({table}.{action}, e.g. post.add). Apply it to the inner sub-router and require_auth to the outer one, so the user is resolved before the permission is checked:

let admin = Router::new()
    .route("/admin", get(admin_only))
    .require_perm("post.add");     // inner: needs the codename

let app = Router::new()
    .route("/profile", get(profile))
    .merge(admin)
    .require_auth(backends);       // outer: resolves the user first
// alice (granted post.add)   → /admin 200
// bob   (authed, no grant)   → /admin 403
// anonymous                  → /admin 401   (auth runs first)

Resolution: a superuser (active) passes everything; a deactivated user is denied even with grants; an explicit per-user override wins over role grants; otherwise any role the user holds that grants the codename passes. Grant with set_user_perm_pool / roles via create_role_pool + assign_role_pool (the permission tables are created by ensure_tables_pool).


The portable registry

Separately, rustango::auth_backends (note: crate root, not tenancy) is a small framework-agnostic registry — a Credentials → Principal chain with its own AuthBackend trait. It has no HTTP/axum glue; use it when you want pluggable auth backends inside your own auth code:

use std::sync::Arc;
use rustango::auth_backends::{
    AuthBackendChain, AuthError, Credentials, Principal, RemoteUserBackend,
};

async fn who(remote_user: &str) -> Result<Option<Principal>, AuthError> {
    let chain = AuthBackendChain::new().with(Arc::new(RemoteUserBackend::trust_username()));
    chain.authenticate(&Credentials::remote(remote_user)).await
}

Same "first success wins / first error stops" semantics as the HTTP chain. For gating real routes, use the tenancy middleware above.


See also

  • API keys and JWT — the credentials ApiKeyBackend / JwtBackend consume.
  • Passwords — the hashing ModelBackend verifies against.
  • Access decorators — per-handler login_required / permission_required gating, the decorator-style alternative to require_auth/require_perm.
  • Sessions — cookie-based auth for browsers.