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.
Connect SQLx or SeaORM to Axum, add authentication and middleware, and test routes. Includes sessions, rate limits, and graceful shutdown.
Middleware runs before and/or after handlers. Axum uses Tower’s Layer and Service model, so Tower and tower-http middleware work directly.
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);
layerApplies to routes and fallbacks already present in that router.
route_layerOnly runs after a route matches, so auth does not turn an unknown route into 401.
Handler::layerWraps a single handler before it becomes a route.
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);
| API | Purpose |
|---|---|
| from_fn / from_fn_with_state | Async function that can inspect a request, call Next, and inspect the response |
| from_extractor / from_extractor_with_state | Run an extractor for its validation side effect and discard the value |
| map_request / map_request_with_state | Asynchronously transform a request before the next service |
| map_response / map_response_with_state | Asynchronously transform a response after the next service |
| Extension(value) | Insert a cloned value into request extensions |
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);
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.
Return routers that are still missing state from feature modules. Supply the actual state once, near server startup.
// 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);
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.
Axum has no database layer. Create a connection pool at startup, store it in application state, and extract that state in handlers.
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.
| Library | Style | Choose it when |
|---|---|---|
| SQLx | Async SQL toolkit | You want explicit SQL, a Tokio-native pool, and optional compile-time query checks |
| SeaORM | Async full ORM | You prefer entity objects, generated models, relations, and a higher-level query API |
| Diesel | Typed query builder / ORM | You value its mature compile-time DSL; use diesel-async when the database work must be async |
[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.
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.
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(())
}
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.
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.
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.
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.
query_as + FromRowConvenient for reusable structs and dynamic query strings. SQL errors are found when the query runs, so integration tests matter.
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.
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.
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.
Fewer abstractions and full query control. Your Rust structs map the returned rows.
More generated types and conventions, with convenient CRUD, relations, and dynamic filters.
[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.
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 type | Beginner meaning |
|---|---|
| Entity | The table-level entry point used to find, insert, update, or delete rows |
| Model | A complete row that was loaded from the database |
| ActiveModel | A row being inserted or changed; each field tracks whether it was set |
| Column | A typed list of columns used for filters, sorting, and joins |
| Relation | Metadata describing foreign-key relationships between entities |
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.
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.
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.
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),
}
}
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),
)
}
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.
use sea_orm::ModelTrait;
let events: Vec<note_event::Model> = saved_note
.find_related(note_event::Entity)
.all(&state.db)
.await?;
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.
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.
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.
[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.
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.
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.
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.
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,
}))
}
Return the same generic unauthorized response for an unknown email and a wrong password. Otherwise the endpoint lets attackers discover registered accounts.
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.
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.
exp, iss, and aud. Never select an algorithm merely because the token header asks for it.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.
| Approach | Client sends | Good fit | Main tradeoff |
|---|---|---|---|
| Bearer token | Authorization: Bearer … | Mobile apps, public APIs, service-to-service calls | Revocation and refresh flows need deliberate design |
| Server session | Secure session cookie | Browser-first sites and admin panels | Shared session storage and CSRF protection are required |
[dependencies]
tower-sessions = "0.15"
time = "0.3"
# Optional higher-level authentication and authorization framework:
axum-login = "0.18"
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);
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)
}
The browser normally holds the random session ID; user and permission data stay in the server-side store.
In production choose a database or Redis-compatible store so every Axum instance sees the same sessions. MemoryStore loses sessions on restart.
It supplies AuthUser, a pluggable authentication backend, an AuthSession extractor, and login/permission route protection.
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.
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.
[dependencies]
utoipa = "5.5"
utoipa-axum = "0.2"
utoipa-swagger-ui = { version = "9.0", features = ["axum", "vendored"] }
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 }))
}
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.
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.
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.
[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"
] }
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);
| Signal | Useful fields | Avoid |
|---|---|---|
| Request log/span | request ID, method, matched route, status, latency | Passwords, tokens, cookies, full request bodies |
| Metrics | request count, error count, latency histogram, active requests, pool usage | Raw URLs or user IDs as labels; they create unbounded cardinality |
| Domain events | job ID, safe resource ID, outcome, retry count | Personal 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.
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.
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.
[dependencies]
tower_governor = "0.8"
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.
One shared bucket limits all callers. Useful as a final overload guard, but one noisy client can consume it.
Useful before login. NAT can group many legitimate users, while proxies can hide the true peer.
Fairer after authentication. Combine it with an IP limit so attackers cannot rotate account names freely.
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.
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.
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.
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.
[dependencies]
axum-server = { version = "0.8", features = ["tls-rustls"] }
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?;
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.
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.
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.
| Tokio tool | Use it when |
|---|---|
| mpsc | Many producers send jobs to one consumer |
| oneshot | One task returns exactly one value to another |
| broadcast | Every active receiver should see each message; useful for WebSockets/SSE |
| watch | Receivers 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 |
| Semaphore | Limit concurrent expensive operations |
| Notify | Wake waiting tasks without sending a value |
| CancellationToken | Cooperative cancellation; provided by tokio-util, not Tokio itself |
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,
}
}
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.
Stop accepting new connections after SIGINT or SIGTERM, then let in-flight connections finish. Your background tasks need their own cancellation and join strategy.
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?;
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.
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");
}
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.
Allow Google Analytics to measure page visits and use of the guides? It uses cookies after you accept. Your choice won’t affect access to the site. Privacy notice