Axum / Chapter 2 of 4

Middleware, databases, and authentication

Connect SQLx or SeaORM to Axum, add authentication and middleware, and test routes. Includes sessions, rate limits, and graceful shutdown.

13 topicsUpdated
10 / 31

Middleware#

Middleware runs before and/or after handlers. Axum uses Tower’s Layer and Service model, so Tower and tower-http middleware work directly.

Rustauthentication with from_fn
use axum::{
    extract::Request,
    http::{header, StatusCode},
    middleware::{self, Next},
    response::Response,
    routing::get,
    Router,
};

async fn require_api_key(
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let valid = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|value| value.to_str().ok())
        .is_some_and(|value| value == "Bearer secret");

    if !valid {
        return Err(StatusCode::UNAUTHORIZED);
    }

    Ok(next.run(request).await)
}

let public = Router::new().route("/health", get(health));
let private = Router::new()
    .route("/account", get(account))
    .route_layer(middleware::from_fn(require_api_key));

let app = public.merge(private);

layer

Applies to routes and fallbacks already present in that router.

route_layer

Only runs after a route matches, so auth does not turn an unknown route into 401.

Handler::layer

Wraps a single handler before it becomes a route.

State in middleware

Rustfrom_fn_with_state
use axum::{
    extract::{Request, State},
    middleware::{self, Next},
    response::Response,
    Router,
};

async fn audit(
    State(state): State<AppState>,
    request: Request,
    next: Next,
) -> Response {
    state.audit.record(request.uri().path()).await;
    next.run(request).await
}

let app = Router::new()
    .route("/notes", get(list_notes))
    .layer(middleware::from_fn_with_state(state.clone(), audit))
    .with_state(state);

Built-in middleware constructors

APIPurpose
from_fn / from_fn_with_stateAsync function that can inspect a request, call Next, and inspect the response
from_extractor / from_extractor_with_stateRun an extractor for its validation side effect and discard the value
map_request / map_request_with_stateAsynchronously transform a request before the next service
map_response / map_response_with_stateAsynchronously transform a response after the next service
Extension(value)Insert a cloned value into request extensions

Production layers from tower-http

Rusttrace + compression + CORS + body limit
use axum::{http::Method, Router};
use tower::ServiceBuilder;
use tower_http::{
    compression::CompressionLayer,
    cors::{Any, CorsLayer},
    limit::RequestBodyLimitLayer,
    trace::TraceLayer,
};

let middleware = ServiceBuilder::new()
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new())
    .layer(RequestBodyLimitLayer::new(4 * 1024 * 1024))
    .layer(
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods([Method::GET, Method::POST]),
    );

let app = Router::new()
    .merge(api_routes())
    .layer(middleware);
Layer order

ServiceBuilder processes requests top-to-bottom and responses bottom-to-top. With repeated Router::layer calls, the last layer added is the outermost and sees the request first.

11 / 31

App structure#

Return routers that are still missing state from feature modules. Supply the actual state once, near server startup.

Rustfeature routers
// notes.rs
pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/", get(list).post(create))
        .route("/{id}", get(show).patch(update).delete(remove))
}

// users.rs
pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/me", get(current_user))
}

// main.rs
let app = Router::new()
    .nest("/api/notes", notes::routes())
    .nest("/api/users", users::routes())
    .route("/health", get(health))
    .with_state(state);
Why this signature works

Router<AppState> from a module advertises that its handlers need AppState. This makes routers easy to nest or merge. Supplying state inside every module makes composition harder.

12 / 31

Database with SQLx#

Axum has no database layer. Create a connection pool at startup, store it in application state, and extract that state in handlers.

The practical default: SQLx

SQLx provides async PostgreSQL, MySQL, and SQLite access, Tokio-compatible connection pools, and compile-time checks for literal SQL. Use SeaORM when you need generated entities and relationship APIs.

LibraryStyleChoose it when
SQLxAsync SQL toolkitYou want explicit SQL, a Tokio-native pool, and optional compile-time query checks
SeaORMAsync full ORMYou prefer entity objects, generated models, relations, and a higher-level query API
DieselTyped query builder / ORMYou value its mature compile-time DSL; use diesel-async when the database work must be async

1. Add SQLx

TOMLCargo.toml · PostgreSQL
[dependencies]
sqlx = { version = "0.9", features = [
  "runtime-tokio",
  "tls-rustls",
  "postgres",
  "macros",
  "migrate",
] }

Swap postgres for mysql or sqlite when needed. A Cargo feature includes optional code from a crate; selecting only one database driver keeps builds smaller.

2. Create a migration

SQLmigrations/202609220001_create_notes.sql
CREATE TABLE notes (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE note_events (
    id BIGSERIAL PRIMARY KEY,
    note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    event TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

A migration is a numbered SQL file that changes the schema. Commit migrations to source control and run each one exactly once per database.

3. Build one pool and put it in state

Ruststartup + migrations
use std::{env, time::Duration};
use axum::{routing::{get, post}, Router};
use sqlx::{postgres::PgPoolOptions, PgPool};
use tokio::net::TcpListener;

#[derive(Clone)]
struct AppState {
    db: PgPool,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database_url = env::var("DATABASE_URL")?;
    let db = PgPoolOptions::new()
        .max_connections(10)
        .acquire_timeout(Duration::from_secs(3))
        .connect(&database_url)
        .await?;

    sqlx::migrate!("./migrations").run(&db).await?;

    let state = AppState { db };
    let app = Router::new()
        .route("/notes", get(list_notes).post(create_note))
        .with_state(state);

    let listener = TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}
Why a pool?

Opening a database connection for every request is slow. PgPool keeps a bounded set of reusable connections. Cloning the pool only clones a small shared handle, so it is safe and cheap to keep inside AppState.

4. Map rows and run queries

Rustlist + create handlers
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;

#[derive(Debug, Serialize, FromRow)]
struct Note {
    id: i64,
    title: String,
    body: String,
}

#[derive(Deserialize)]
struct CreateNote {
    title: String,
    body: String,
}

async fn list_notes(
    State(state): State<AppState>,
) -> Result<Json<Vec<Note>>, StatusCode> {
    let notes = sqlx::query_as::<_, Note>(
        "SELECT id, title, body FROM notes ORDER BY id DESC"
    )
    .fetch_all(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(notes))
}

async fn create_note(
    State(state): State<AppState>,
    Json(input): Json<CreateNote>,
) -> Result<(StatusCode, Json<Note>), StatusCode> {
    let note = sqlx::query_as::<_, Note>(
        "INSERT INTO notes (title, body) VALUES ($1, $2)
         RETURNING id, title, body"
    )
    .bind(&input.title)
    .bind(&input.body)
    .fetch_one(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((StatusCode::CREATED, Json(note)))
}

FromRow tells SQLx how column names become struct fields. bind sends values separately from the SQL text, which prevents user input from becoming executable SQL. Never assemble a query with format! and untrusted values.

5. Use a transaction for all-or-nothing work

Rusttransaction
async fn create_note_with_event(
    db: &PgPool,
    title: &str,
    body: &str,
) -> Result<i64, sqlx::Error> {
    let mut tx = db.begin().await?;

    let note_id: i64 = sqlx::query_scalar(
        "INSERT INTO notes (title, body) VALUES ($1, $2) RETURNING id"
    )
    .bind(title)
    .bind(body)
    .fetch_one(&mut *tx)
    .await?;

    sqlx::query("INSERT INTO note_events (note_id, event) VALUES ($1, $2)")
        .bind(note_id)
        .bind("created")
        .execute(&mut *tx)
        .await?;

    tx.commit().await?;
    Ok(note_id)
}

If any step returns an error before commit, dropping the transaction rolls it back. Use this for changes that must either all succeed or all disappear.

Compile-time checked

query! / query_as!

Literal SQL is checked against a development database during compilation, or against checked-in offline metadata. This catches column and type mistakes early.

Flexible mapping

query_as + FromRow

Convenient for reusable structs and dynamic query strings. SQL errors are found when the query runs, so integration tests matter.

Pool size is a shared budget

Ten application replicas with a 20-connection pool can open 200 database connections. Set a short acquire timeout, monitor pool wait time, and budget the total against the database limit.

13 / 31

Database with SeaORM#

SeaORM is an asynchronous, full ORM built on SQLx. Instead of writing SQL for every operation, you work with generated Rust types for tables, rows, columns, and relationships.

When SeaORM is the better choice

Choose SeaORM 2.0 when you want entity models, typed dynamic queries, relation loading, and higher-level CRUD. Keep SQLx when direct SQL, minimal abstraction, or database-specific queries matter more. SeaORM uses SQLx drivers internally, so it remains Tokio-native.

SQLx

You write the SQL

Fewer abstractions and full query control. Your Rust structs map the returned rows.

SeaORM

You query entities

More generated types and conventions, with convenient CRUD, relations, and dynamic filters.

1. Add SeaORM for Axum and PostgreSQL

TOMLCargo.toml
[dependencies]
sea-orm = { version = "2.0", features = [
  "sqlx-postgres",
  "runtime-tokio-rustls",
  "macros",
  "with-chrono",
] }

sqlx-postgres selects the database driver, runtime-tokio-rustls matches Axum’s async runtime and enables TLS, and macros generates the entity types. Use sqlx-mysql or sqlx-sqlite for another database.

2. Generate entities from the existing schema

Shellterminal
cargo install sea-orm-cli@^2.0

# Reads DATABASE_URL and creates one Rust module per table.
sea-orm-cli generate entity --output-dir src/entity

This is the schema-first workflow: run migrations, then generate entities from the real database. For a new project, sea-orm-cli migrate init creates a migration crate; sea-orm-cli migrate generate create_notes creates a migration file.

Generated typeBeginner meaning
EntityThe table-level entry point used to find, insert, update, or delete rows
ModelA complete row that was loaded from the database
ActiveModelA row being inserted or changed; each field tracks whether it was set
ColumnA typed list of columns used for filters, sorting, and joins
RelationMetadata describing foreign-key relationships between entities

3. What a SeaORM 2.0 entity looks like

Rustsrc/entity/note.rs
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "notes")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i64,
    pub title: String,
    pub body: String,
    pub created_at: DateTimeWithTimeZone,
}

impl ActiveModelBehavior for ActiveModel {}

The macro generates Entity, ActiveModel, Column, and primary-key types from this struct. In practice, let the CLI generate entity files after schema changes instead of typing them by hand.

4. Connect once and share the connection through Axum state

Ruststartup + state
use std::{env, time::Duration};
use axum::Router;
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use migration::{Migrator, MigratorTrait};

#[derive(Clone)]
struct SeaOrmState {
    db: DatabaseConnection,
}

let mut options = ConnectOptions::new(env::var("DATABASE_URL")?);
options
    .max_connections(10)
    .min_connections(2)
    .connect_timeout(Duration::from_secs(3))
    .sqlx_logging(false);

let db = Database::connect(options).await?;
Migrator::up(&db, None).await?;

let state = SeaOrmState { db };
let app = Router::new()
    .nest("/api", seaorm_routes())
    .with_state(state);

DatabaseConnection owns a connection pool and is cheap to clone. Migrator::up applies pending migrations from the generated migration crate before the server starts accepting requests.

5. Create and list rows from Axum handlers

RustPOST + GET /api/notes
use axum::{
    extract::State,
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize};

use crate::entity::note;

#[derive(Deserialize)]
struct CreateNote {
    title: String,
    body: String,
}

#[derive(Serialize)]
struct NoteResponse {
    id: i64,
    title: String,
    body: String,
}

impl From<note::Model> for NoteResponse {
    fn from(note: note::Model) -> Self {
        Self { id: note.id, title: note.title, body: note.body }
    }
}

async fn list_notes(
    State(state): State<SeaOrmState>,
) -> Result<Json<Vec<NoteResponse>>, StatusCode> {
    let notes = note::Entity::find()
        .order_by_desc(note::Column::Id)
        .all(&state.db)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(notes.into_iter().map(Into::into).collect()))
}

async fn create_note(
    State(state): State<SeaOrmState>,
    Json(input): Json<CreateNote>,
) -> Result<(StatusCode, Json<NoteResponse>), StatusCode> {
    let note = note::ActiveModel {
        title: Set(input.title),
        body: Set(input.body),
        ..Default::default()
    }
    .insert(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((StatusCode::CREATED, Json(note.into())))
}

Set(value) marks a field for insertion. Fields left as NotSet are omitted, allowing the database to generate the ID and created_at. Separate response structs keep database-only fields out of the API.

6. Read, update, and delete by ID

RustGET + PATCH + DELETE /api/notes/{id}
use axum::extract::Path;
use sea_orm::{ActiveModelTrait, EntityTrait, Set};

#[derive(Deserialize)]
struct UpdateNote {
    title: Option<String>,
    body: Option<String>,
}

async fn show_note(
    State(state): State<SeaOrmState>,
    Path(id): Path<i64>,
) -> Result<Json<NoteResponse>, StatusCode> {
    let note = note::Entity::find_by_id(id)
        .one(&state.db)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or(StatusCode::NOT_FOUND)?;
    Ok(Json(note.into()))
}

async fn update_note(
    State(state): State<SeaOrmState>,
    Path(id): Path<i64>,
    Json(input): Json<UpdateNote>,
) -> Result<Json<NoteResponse>, StatusCode> {
    let model = note::Entity::find_by_id(id)
        .one(&state.db)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or(StatusCode::NOT_FOUND)?;

    let mut active: note::ActiveModel = model.into();
    if let Some(title) = input.title { active.title = Set(title); }
    if let Some(body) = input.body { active.body = Set(body); }

    let updated = active
        .update(&state.db)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(updated.into()))
}

async fn delete_note(
    State(state): State<SeaOrmState>,
    Path(id): Path<i64>,
) -> Result<StatusCode, StatusCode> {
    let result = note::Entity::delete_by_id(id)
        .exec(&state.db)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    match result.rows_affected {
        0 => Err(StatusCode::NOT_FOUND),
        _ => Ok(StatusCode::NO_CONTENT),
    }
}

7. Assemble the SeaORM routes

Rustrouter
use axum::{routing::get, Router};

fn seaorm_routes() -> Router<SeaOrmState> {
    Router::new()
        .route("/notes", get(list_notes).post(create_note))
        .route(
            "/notes/{id}",
            get(show_note).patch(update_note).delete(delete_note),
        )
}

8. Relations and transactions

Rustnote + audit event transaction
use sea_orm::{
    ActiveModelTrait, DatabaseConnection, DbErr, Set, TransactionTrait,
};
use crate::entity::{note, note_event};

async fn create_note_with_event(
    db: &DatabaseConnection,
    title: String,
    body: String,
) -> Result<note::Model, DbErr> {
    let transaction = db.begin().await?;

    let saved_note = note::ActiveModel {
        title: Set(title),
        body: Set(body),
        ..Default::default()
    }
    .insert(&transaction)
    .await?;

    note_event::ActiveModel {
        note_id: Set(saved_note.id),
        event: Set("created".to_owned()),
        ..Default::default()
    }
    .insert(&transaction)
    .await?;

    transaction.commit().await?;
    Ok(saved_note)
}

The same entity methods accept either &DatabaseConnection or &DatabaseTransaction. A transaction commits all changes together; dropping it before commit rolls them back.

Rustload a note's related events
use sea_orm::ModelTrait;

let events: Vec<note_event::Model> = saved_note
    .find_related(note_event::Entity)
    .all(&state.db)
    .await?;
Avoid hidden N+1 queries

Loading a relation separately inside a loop sends one query per row. Use SeaORM’s Entity Loader or relation-loading APIs to fetch related rows in batches, and inspect generated SQL while developing.

14 / 31

API authentication#

Authentication proves who sent a request. For a stateless JSON API, the common Axum building blocks are jsonwebtoken for bearer tokens and argon2 for securely hashing passwords.

Choose the flow before the crate

Use jsonwebtoken for API clients that send Authorization: Bearer …. For a browser-first app with server-side cookie sessions, prefer axum-login with tower-sessions. If an identity provider issues tokens, validate its tokens instead of building password login yourself.

1. Add token and password libraries

TOMLCargo.toml
[dependencies]
jsonwebtoken = { version = "11", features = ["rust_crypto"] }
argon2 = { version = "0.6", features = ["getrandom"] }

A password hash is deliberately slow and contains a random salt. Store the encoded Argon2id string in the database; never store the original password. A JWT is a signed set of claims—not encrypted storage—so do not place secrets inside it.

Minimal users table

SQLmigrations/202609220002_create_users.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

During registration, validate and normalize the email, call hash_password, then bind the returned PHC string into password_hash. The database never needs the plaintext password.

2. Hash during registration; verify during login

RustArgon2id helpers
use argon2::{
    password_hash::{phc::PasswordHash, PasswordHasher, PasswordVerifier},
    Argon2,
};

async fn hash_password(password: String) -> Result<String, AppError> {
    tokio::task::spawn_blocking(move || {
        Argon2::default()
            .hash_password(password.as_bytes())
            .map(|hash| hash.to_string())
            .map_err(|_| AppError::Internal)
    })
    .await
    .map_err(|_| AppError::Internal)?
}

async fn password_matches(password: String, encoded: String) -> bool {
    tokio::task::spawn_blocking(move || {
        let Ok(hash) = PasswordHash::new(&encoded) else {
            return false;
        };
        Argon2::default()
            .verify_password(password.as_bytes(), &hash)
            .is_ok()
    })
    .await
    .unwrap_or(false)
}

Argon2 is CPU- and memory-intensive on purpose, so spawn_blocking keeps that work off Tokio’s async worker threads. Apply rate limits to login and registration too.

3. Configure signing and validation once

RustJWT state
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Validation};
use serde::{Deserialize, Serialize};

#[derive(Clone)]
struct JwtKeys {
    encoding: EncodingKey,
    decoding: DecodingKey,
    validation: Validation,
}

impl JwtKeys {
    fn from_secret(secret: &str) -> Self {
        let mut validation = Validation::new(Algorithm::HS256);
        validation.set_issuer(&["notes-api"]);
        validation.set_audience(&["notes-client"]);
        validation.set_required_spec_claims(&["exp", "iss", "aud"]);

        Self {
            encoding: EncodingKey::from_secret(secret.as_bytes()),
            decoding: DecodingKey::from_secret(secret.as_bytes()),
            validation,
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Claims {
    sub: String, // subject: the authenticated user's id
    iat: u64,    // issued-at time, in Unix seconds
    exp: u64,    // expiration time, in Unix seconds
    iss: String, // issuer: who created this token
    aud: String, // audience: which API/client may use it
}

// Extend the earlier state rather than creating another pool.
#[derive(Clone)]
struct AppState {
    db: PgPool,
    jwt: JwtKeys,
}

Read JWT_SECRET from a secret manager or environment variable and use at least 32 random bytes for HS256. Reuse key objects from state. In multi-service systems, asymmetric keys or an external identity provider usually make rotation and verification safer.

4. Check credentials and issue a short-lived token

RustPOST /auth/login
use jsonwebtoken::{encode, get_current_timestamp, Header};
use sqlx::FromRow;

#[derive(Deserialize)]
struct LoginInput {
    email: String,
    password: String,
}

#[derive(FromRow)]
struct UserAuth {
    id: i64,
    password_hash: String,
}

#[derive(Serialize)]
struct TokenResponse {
    access_token: String,
    token_type: &'static str,
    expires_in: u64,
}

async fn login(
    State(state): State<AppState>,
    Json(input): Json<LoginInput>,
) -> Result<Json<TokenResponse>, StatusCode> {
    let user = sqlx::query_as::<_, UserAuth>(
        "SELECT id, password_hash FROM users WHERE email = $1"
    )
    .bind(&input.email)
    .fetch_optional(&state.db)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::UNAUTHORIZED)?;

    if !password_matches(input.password, user.password_hash).await {
        return Err(StatusCode::UNAUTHORIZED);
    }

    let now = get_current_timestamp();
    let claims = Claims {
        sub: user.id.to_string(),
        iat: now,
        exp: now + 15 * 60,
        iss: "notes-api".into(),
        aud: "notes-client".into(),
    };
    let token = encode(
        &Header::new(Algorithm::HS256),
        &claims,
        &state.jwt.encoding,
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(TokenResponse {
        access_token: token,
        token_type: "Bearer",
        expires_in: 15 * 60,
    }))
}
Do not reveal which credential failed

Return the same generic unauthorized response for an unknown email and a wrong password. Otherwise the endpoint lets attackers discover registered accounts.

5. Validate the bearer token in middleware

Rustprotected routes
use axum::{
    extract::{Request, State},
    http::{header, StatusCode},
    middleware::{self, Next},
    response::Response,
    Extension, Router,
};
use jsonwebtoken::decode;

async fn require_auth(
    State(state): State<AppState>,
    mut request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let token = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.strip_prefix("Bearer "))
        .ok_or(StatusCode::UNAUTHORIZED)?;

    let token_data = decode::<Claims>(
        token,
        &state.jwt.decoding,
        &state.jwt.validation,
    )
    .map_err(|_| StatusCode::UNAUTHORIZED)?;

    request.extensions_mut().insert(token_data.claims);
    Ok(next.run(request).await)
}

async fn me(Extension(claims): Extension<Claims>) -> Json<Claims> {
    Json(claims)
}

let protected = Router::new()
    .route("/me", get(me))
    .route_layer(middleware::from_fn_with_state(
        state.clone(),
        require_auth,
    ));

let app = Router::new()
    .route("/auth/login", post(login))
    .merge(protected)
    .with_state(state);

The middleware verifies the signature, fixed algorithm, expiry, issuer, and audience before placing trusted claims into request extensions. route_layer protects only routes that already exist, so unknown paths still return 404 instead of 401.

Authentication is not authorization

A valid token proves identity; it does not grant access to every row. Scope database queries to that identity—for example, SELECT … FROM notes WHERE id = $1 AND owner_id = $2—and check roles or permissions for every protected action.

Production checklist

15 / 31

Cookies & server sessions#

A browser can keep a small opaque session ID in a cookie while the server stores the user’s session data. This is often simpler than JWTs for a traditional website.

ApproachClient sendsGood fitMain tradeoff
Bearer tokenAuthorization: Bearer …Mobile apps, public APIs, service-to-service callsRevocation and refresh flows need deliberate design
Server sessionSecure session cookieBrowser-first sites and admin panelsShared session storage and CSRF protection are required

1. Install and configure tower-sessions

TOMLCargo.toml
[dependencies]
tower-sessions = "0.15"
time = "0.3"

# Optional higher-level authentication and authorization framework:
axum-login = "0.18"
Rustsession middleware
use time::Duration;
use tower_sessions::{
    cookie::SameSite,
    Expiry, MemoryStore, SessionManagerLayer,
};

// MemoryStore is convenient for local development and tests only.
let store = MemoryStore::default();

let session_layer = SessionManagerLayer::new(store)
    .with_name("notes.sid")
    .with_http_only(true)          // JavaScript cannot read the cookie
    .with_secure(true)             // browser sends it only over HTTPS
    .with_same_site(SameSite::Lax)
    .with_expiry(Expiry::OnInactivity(Duration::hours(8)));

let app = routes().layer(session_layer);

2. Sign in, read the user ID, and sign out

RustSession extractor
use axum::{http::StatusCode, Json};
use tower_sessions::Session;

async fn session_login(
    session: Session,
    Json(input): Json<Login>,
) -> Result<StatusCode, AppError> {
    let user = verify_credentials(input).await?;

    // Replace any pre-login ID to prevent session fixation.
    session.cycle_id().await?;
    session.insert("user_id", user.id).await?;

    Ok(StatusCode::NO_CONTENT)
}

async fn session_me(
    session: Session,
) -> Result<Json<PublicUser>, AppError> {
    let user_id: i64 = session
        .get("user_id")
        .await?
        .ok_or(AppError::Unauthorized)?;

    Ok(Json(load_public_user(user_id).await?))
}

async fn session_logout(session: Session) -> Result<StatusCode, AppError> {
    session.flush().await?; // remove data and delete the stored session
    Ok(StatusCode::NO_CONTENT)
}
Cookie

Store only an identifier

The browser normally holds the random session ID; user and permission data stay in the server-side store.

Storage

Use a shared backend

In production choose a database or Redis-compatible store so every Axum instance sees the same sessions. MemoryStore loses sessions on restart.

Framework

Add axum-login when useful

It supplies AuthUser, a pluggable authentication backend, an AuthSession extractor, and login/permission route protection.

Cookies introduce CSRF risk

Browsers attach cookies automatically. Use SameSite, verify Origin on unsafe methods, and use a CSRF token when cross-site or legacy-browser behavior matters. Keep Secure and HttpOnly enabled, rotate the session ID after login or privilege changes, and never put passwords or access tokens in logs.

16 / 31

OpenAPI & Swagger UI#

OpenAPI is a machine-readable description of your HTTP API. Utoipa derives schemas from Rust types, and utoipa-axum registers a route and its documentation together to reduce drift.

1. Add the documentation crates

TOMLCargo.toml
[dependencies]
utoipa = "5.5"
utoipa-axum = "0.2"
utoipa-swagger-ui = { version = "9.0", features = ["axum", "vendored"] }

2. Describe the types and endpoint

Rustschema + path
use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Deserialize, ToSchema)]
struct CreateNote {
    /// Human-readable title shown in note lists.
    #[schema(example = "Learn Axum", min_length = 1, max_length = 120)]
    title: String,
}

#[derive(Serialize, ToSchema)]
struct Note {
    id: u64,
    title: String,
}

#[utoipa::path(
    post,
    path = "/notes",
    request_body = CreateNote,
    responses(
        (status = 201, description = "Note created", body = Note),
        (status = 400, description = "Invalid input")
    ),
    tag = "notes"
)]
async fn documented_create_note(
    Json(input): Json<CreateNote>,
) -> (StatusCode, Json<Note>) {
    (StatusCode::CREATED, Json(Note { id: 42, title: input.title }))
}

3. Collect routes and serve the UI

RustOpenApiRouter
use utoipa_axum::{router::OpenApiRouter, routes};
use utoipa_swagger_ui::SwaggerUi;

let (api_router, openapi) = OpenApiRouter::new()
    .routes(routes!(documented_create_note))
    .split_for_parts();

let app = api_router.merge(
    SwaggerUi::new("/swagger-ui")
        .url("/api-docs/openapi.json", openapi),
);

The interactive UI is at /swagger-ui; the JSON specification is at /api-docs/openapi.json. Only handlers registered through routes! are added automatically. Document path/query parameters, every response status, and reusable error bodies—not just the happy path.

Treat the specification as a tested contract

Add a named bearer or cookie security scheme, mark protected operations with security(...), snapshot or validate the generated JSON in CI, and restrict Swagger UI in production if it exposes internal endpoints. OpenAPI describes validation; it does not perform it.

17 / 31

Production observability#

Logs explain individual events, traces connect work across a request, and metrics reveal trends. Add request IDs, structured fields, safe header handling, and time limits before an incident.

1. Enable structured tracing and HTTP layers

TOMLCargo.toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tower-http = { version = "0.7", features = [
    "trace", "request-id", "sensitive-headers", "timeout"
] }
Rustlogs + request IDs + timeout
use axum::http::{header, HeaderName, StatusCode};
use std::time::Duration;
use tower::ServiceBuilder;
use tower_http::{
    request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
    sensitive_headers::SetSensitiveRequestHeadersLayer,
    timeout::TimeoutLayer,
    trace::TraceLayer,
};
use tracing_subscriber::EnvFilter;

tracing_subscriber::fmt()
    .with_env_filter(
        EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| "notes_api=info,tower_http=info".into()),
    )
    .json()
    .init();

let request_id = HeaderName::from_static("x-request-id");
let http_layers = ServiceBuilder::new()
    // Mark secrets before TraceLayer can format request headers.
    .layer(SetSensitiveRequestHeadersLayer::new([
        header::AUTHORIZATION,
        header::COOKIE,
    ]))
    // The request ID must exist before TraceLayer creates its span.
    .layer(SetRequestIdLayer::new(request_id.clone(), MakeRequestUuid))
    .layer(TraceLayer::new_for_http())
    // Add the same ID to the response returned to the caller.
    .layer(PropagateRequestIdLayer::new(request_id))
    .layer(TimeoutLayer::with_status_code(
        StatusCode::REQUEST_TIMEOUT,
        Duration::from_secs(15),
    ));

let app = routes().layer(http_layers);
SignalUseful fieldsAvoid
Request log/spanrequest ID, method, matched route, status, latencyPasswords, tokens, cookies, full request bodies
Metricsrequest count, error count, latency histogram, active requests, pool usageRaw URLs or user IDs as labels; they create unbounded cardinality
Domain eventsjob ID, safe resource ID, outcome, retry countPersonal data unless collection is justified and protected

Use Axum’s MatchedPath value—such as /notes/{id}—for route labels instead of the raw URI /notes/8472. Export traces and metrics through an OpenTelemetry or Prometheus-compatible stack when the application needs cross-service visibility.

Timeouts have more than one boundary

A request-future timeout limits how long the handler takes to produce a response. Streaming request and response bodies need RequestBodyTimeoutLayer, ResponseBodyTimeoutLayer, or deadline variants. Align application, database, proxy, and client timeouts so cancellation is predictable.

18 / 31

Rate limiting#

A rate limiter limits how quickly one key—an IP, account, token, or the whole service—may call a route. It protects capacity and slows brute-force abuse; it is not a substitute for authorization.

Per-IP limit with tower-governor

TOMLCargo.toml
[dependencies]
tower_governor = "0.8"
Rustlimit sensitive auth routes
use axum::{routing::post, Router};
use std::net::SocketAddr;
use tower_governor::{
    governor::GovernorConfigBuilder,
    GovernorLayer,
};

// Each peer IP may burst to 10 requests. One allowance returns per second.
let governor = GovernorConfigBuilder::default()
    .per_second(1)
    .burst_size(10)
    .use_headers()
    .finish()
    .expect("non-zero rate-limit values");

let limited_auth = Router::new()
    .route("/auth/login", post(login))
    .route("/auth/password-reset", post(password_reset))
    .route_layer(GovernorLayer::new(governor));

let app = Router::new()
    .route("/health/live", axum::routing::get(live))
    .merge(limited_auth);

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(
    listener,
    app.into_make_service_with_connect_info::<SocketAddr>(),
).await?;

The default key is the socket peer IP. use_headers() adds rate-limit information and Retry-After behavior. For an authenticated API, a custom key extractor can limit by account or API key; use separate, stricter policies for login, registration, password reset, exports, and expensive searches.

Global

Protect total capacity

One shared bucket limits all callers. Useful as a final overload guard, but one noisy client can consume it.

IP

Protect anonymous routes

Useful before login. NAT can group many legitimate users, while proxies can hide the true peer.

Identity

Protect accounts or keys

Fairer after authentication. Combine it with an IP limit so attackers cannot rotate account names freely.

One process is not one deployment

An in-memory limiter counts independently in every Axum replica, so ten replicas allow roughly ten times the intended traffic. Enforce a truly global policy at an API gateway or with shared storage. Behind a proxy, never trust arbitrary X-Forwarded-For values; accept forwarded identity only from known proxy addresses.

19 / 31

Reverse proxies, TLS & health checks#

Most production Axum services sit behind a load balancer or reverse proxy. That component often terminates HTTPS, chooses a healthy instance, and forwards a plain HTTP request over a private network.

01ClientSends HTTPS to the public hostname.
02Proxy / load balancerTerminates TLS, adds trusted forwarding metadata, and selects an instance.
03AxumHandles the request on a private listener and reports readiness.

Know what ConnectInfo means

Rustsocket peer address
use axum::{extract::ConnectInfo, routing::get, Router};
use std::net::SocketAddr;

async fn peer(ConnectInfo(address): ConnectInfo<SocketAddr>) -> String {
    format!("TCP peer: {}", address.ip())
}

let app = Router::new().route("/debug/peer", get(peer));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;

axum::serve(
    listener,
    app.into_make_service_with_connect_info::<SocketAddr>(),
).await?;

Without a proxy, the peer is usually the client. Behind a proxy, it is usually the proxy. Trust Forwarded, X-Forwarded-For, X-Forwarded-Proto, or X-Forwarded-Host only when the immediate peer belongs to a configured proxy network and that proxy replaces or sanitizes incoming copies. Otherwise a caller can spoof IP-based logs, redirects, and rate limits.

Liveness and readiness are different

Rusthealth endpoints
use axum::{extract::State, http::StatusCode, routing::get, Router};

async fn live() -> StatusCode {
    // The process and Tokio runtime can answer requests.
    StatusCode::OK
}

async fn ready(State(state): State<AppState>) -> StatusCode {
    // A cheap dependency check: can this instance serve normal traffic?
    match tokio::time::timeout(
        std::time::Duration::from_millis(500),
        state.db.acquire(),
    ).await {
        Ok(Ok(_connection)) => StatusCode::OK,
        _ => StatusCode::SERVICE_UNAVAILABLE,
    }
}

let app = Router::new()
    .route("/health/live", get(live))
    .route("/health/ready", get(ready))
    .with_state(state);

Liveness should not fail because the database is briefly unavailable; a restart loop can make an outage worse. Readiness removes the instance from new traffic during startup, dependency loss, or shutdown draining. Keep both endpoints cheap and exempt them from ordinary authentication and per-user rate limits.

Direct TLS with rustls when there is no TLS proxy

TOMLCargo.toml
[dependencies]
axum-server = { version = "0.8", features = ["tls-rustls"] }
RustHTTPS listener
use axum_server::tls_rustls::RustlsConfig;

let tls = RustlsConfig::from_pem_file(
    "certs/fullchain.pem",
    "certs/private-key.pem",
).await?;

let address = "0.0.0.0:443".parse()?;
axum_server::bind_rustls(address, tls)
    .serve(app.into_make_service())
    .await?;
Coordinate the whole request path

Set compatible body-size, header-size, idle, streaming, and request timeouts at the proxy and application. During deployment, mark the instance unready, stop accepting new requests, allow in-flight work to finish through graceful shutdown, and give the orchestrator a termination grace period longer than the application drain timeout.

20 / 31

Tokio essentials#

Axum runs on Tokio. Use tasks for independent async work, async channels for communication, and spawn_blocking for code that would block a runtime worker.

Tasks and blocking work

Rustspawn vs spawn_blocking
async fn create_report() -> Result<StatusCode, AppError> {
    // Independent async I/O work.
    tokio::spawn(async {
        send_audit_event().await;
    });

    // CPU-heavy or blocking library call.
    let pdf = tokio::task::spawn_blocking(|| render_pdf_sync())
        .await
        .map_err(|_| AppError::Internal)?;

    save_pdf(pdf).await?;
    Ok(StatusCode::ACCEPTED)
}

tokio::spawn needs a Send + 'static future. A dropped JoinHandle detaches the task; call abort or use structured task ownership when it must not outlive its request or component.

Channels and synchronization

Tokio toolUse it when
mpscMany producers send jobs to one consumer
oneshotOne task returns exactly one value to another
broadcastEvery active receiver should see each message; useful for WebSockets/SSE
watchReceivers only need the newest configuration or status value
Mutex<T>One async task at a time mutates shared data, possibly across await
RwLock<T>Many readers or one writer; reads greatly outnumber writes
SemaphoreLimit concurrent expensive operations
NotifyWake waiting tasks without sending a value
CancellationTokenCooperative cancellation; provided by tokio-util, not Tokio itself

Timeout, interval, and select

Rustrace work against cancellation
use std::time::Duration;
use tokio::time::{interval, timeout};

let value = timeout(Duration::from_secs(2), load_note(id))
    .await
    .map_err(|_| AppError::Internal)??;

let mut ticks = interval(Duration::from_secs(30));
loop {
    tokio::select! {
        _ = ticks.tick() => refresh_cache().await,
        _ = tokio::signal::ctrl_c() => break,
    }
}
Cancellation is drop

When select! chooses one branch, other branch futures are dropped. Make sure the operations you place there are cancellation-safe, especially framed reads, buffered writes, and multi-step state changes.

21 / 31

Graceful shutdown#

Stop accepting new connections after SIGINT or SIGTERM, then let in-flight connections finish. Your background tasks need their own cancellation and join strategy.

Rustcross-platform shutdown signal
use tokio::signal;

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c().await.expect("install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
}

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;

axum::serve(listener, app)
    .with_graceful_shutdown(shutdown_signal())
    .await?;
22 / 31

Testing#

Test a router as a Tower service. This is fast and does not open a network port. Use a real listener only when transport behavior itself matters.

Rustrouter test with oneshot
use axum::{
    body::Body,
    http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;

#[tokio::test]
async fn health_returns_ok() {
    let app = build_app(test_state());

    let response = app
        .oneshot(
            Request::builder()
                .uri("/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);

    let bytes = response.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(&bytes[..], b"ok");
}

Test JSON input

RustPOST request
let request = Request::builder()
    .method("POST")
    .uri("/notes")
    .header("content-type", "application/json")
    .body(Body::from(r#"{"title":"learn axum","body":"start small"}"#))
    .unwrap();

let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);

For handlers that use ConnectInfo, add MockConnectInfo(peer) as a layer in tests. For deterministic time-based tests, enable Tokio’s test-util feature and use paused time.