Lesson 12 of 30
Authentication and Authorization in Axum
Authentication establishes who is calling. Authorization decides what that caller may do. This lesson builds a Storefront service with Argon2 password hashing, server-side sessions, an authentication extractor, role checks, ownership checks, and tenant isolation on PostgreSQL. A principal is the authenticated identity. A tenant is a customer organization whose data must stay separate from other tenants.
Project setup
Wait a few seconds after docker run until PostgreSQL accepts connections, then run the psql line.
docker run --name pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=storefront -p 5432:5432 -d postgres:16
docker exec -i pg psql -U postgres -d storefront < migrations/0001_auth.sql
export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/storefrontCargo.toml
# Cargo.toml
[package]
name = "storefront-auth"
version = "0.1.0"
edition = "2021"
[dependencies]
argon2 = "0.5"
axum = "0.8"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }migrations/0001_auth.sql
-- migrations/0001_auth.sql
CREATE TABLE tenants (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
INSERT INTO tenants (name) VALUES ('acme'), ('globex');
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants (id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'customer'
CHECK (role IN ('customer', 'support', 'admin')),
UNIQUE (tenant_id, id)
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'placed',
total_cents BIGINT NOT NULL CHECK (total_cents > 0),
FOREIGN KEY (tenant_id, customer_id) REFERENCES users (tenant_id, id)
);users has UNIQUE (tenant_id, id), and orders has a composite foreign key on it. The database rejects an order whose customer belongs to another tenant.
Sessions or tokens
After login the caller needs to prove identity without resending the password. Two designs exist. This service uses server-side sessions. Signed tokens (JWT) are covered in OAuth2, OIDC, JWT, and mTLS.
| Property | Server-side session | Signed token |
|---|---|---|
| Client holds | A random opaque id | A signed document with claims |
| State lives in | A store keyed by the id | The token |
| Validation | One lookup per request | Signature and claims check, no lookup |
| Revocation | Delete the row. Effective at once | Valid until expiry unless a deny list is checked |
| Role changes | Read live from the store | Visible after the token is reissued |
A bearer token is a credential where possession is enough, so both designs need TLS and a short lifetime. A session cookie needs HttpOnly (scripts cannot read it), Secure (HTTPS only), and SameSite=Lax or Strict (limits cross-site sending, which reduces cross-site request forgery). This service sends the token in an Authorization: Bearer header, which browsers do not attach automatically.
Example 1: Hash passwords with Argon2
A service stores the output of a slow, salted, one-way function and never the password. A salt is random data added to the password before hashing, so equal passwords give different hashes. Argon2id is a memory-hard function: each guess needs a configurable amount of memory and time, which makes bulk guessing expensive.
src/password.rs
// src/password.rs
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, stored: &str) -> bool {
match PasswordHash::new(stored) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
/// Hashing is CPU heavy. `spawn_blocking` keeps it off the async worker threads.
pub async fn hash_async(password: String) -> Option<String> {
tokio::task::spawn_blocking(move || hash_password(&password).ok())
.await
.ok()
.flatten()
}
pub async fn verify_async(password: String, stored: String) -> bool {
tokio::task::spawn_blocking(move || verify_password(&password, &stored))
.await
.unwrap_or(false)
}src/main.rs
// src/main.rs
mod password;
fn main() {
let stored = password::hash_password("correct horse").expect("hash");
let algorithm = stored.split('$').nth(1).unwrap_or("");
println!("{algorithm}");
println!("{}", password::verify_password("correct horse", &stored));
println!("{}", password::verify_password("wrong", &stored));
}argon2id
true
false- The stored string is in PHC format: algorithm, parameters, salt, and hash in one string. Parameters travel with each hash, so cost can be raised later and a hash upgraded at the next login.
- Hashing uses the CPU for a long time.
spawn_blockingmoves it off the async worker threads (blocking and CPU work). - A fast hash such as SHA-256 is not suitable for passwords, because speed helps an attacker.
Example 2: Register and log in
register hashes the password and inserts a user with the default role. login verifies the password and stores a session. The response carries a random token, and the database keeps only its SHA-256.
For an unknown email, login verifies against a dummy hash created at startup. Both failures then return the same error after similar work, so the response does not reveal which emails exist.
src/error.rs
// src/error.rs
use axum::{
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub enum ApiError {
Unauthorized,
InvalidCredentials,
Forbidden,
NotFound,
Conflict,
Invalid(&'static str),
Internal,
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
if let sqlx::Error::Database(db) = &err {
if db.is_unique_violation() {
return ApiError::Conflict;
}
if db.is_foreign_key_violation() {
return ApiError::Invalid("unknown reference");
}
}
eprintln!("database error: {err}");
ApiError::Internal
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::Unauthorized => {
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
Json(json!({ "error": "unauthorized" })),
)
.into_response();
}
ApiError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid credentials"),
ApiError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"),
ApiError::Conflict => (StatusCode::CONFLICT, "conflict"),
ApiError::Invalid(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/auth.rs
// src/auth.rs
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{error::ApiError, password, AppState};
#[derive(Deserialize)]
pub struct Register {
email: String,
password: String,
tenant_id: i64,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct UserDto {
id: i64,
tenant_id: i64,
email: String,
role: String,
}
#[derive(Deserialize)]
pub struct Login {
email: String,
password: String,
}
#[derive(Serialize)]
pub struct TokenDto {
token: String,
}
/// A random session token: 244 random bits as 64 hex characters.
fn new_token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
/// The database stores only the SHA-256 of the token, so a database leak does not reveal sessions.
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
pub async fn register(
State(state): State<AppState>,
Json(body): Json<Register>,
) -> Result<(StatusCode, Json<UserDto>), ApiError> {
if !body.email.contains('@') {
return Err(ApiError::Invalid("invalid email"));
}
if body.password.chars().count() < 8 {
return Err(ApiError::Invalid("password needs at least 8 characters"));
}
let hash = password::hash_async(body.password)
.await
.ok_or(ApiError::Internal)?;
// The role is never taken from the request. New users are customers.
let user = sqlx::query_as::<_, UserDto>(
"INSERT INTO users (tenant_id, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, email, role",
)
.bind(body.tenant_id)
.bind(&body.email)
.bind(hash)
.fetch_one(&state.pool)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}
pub async fn login(
State(state): State<AppState>,
Json(body): Json<Login>,
) -> Result<Json<TokenDto>, ApiError> {
let row: Option<(i64, String)> =
sqlx::query_as("SELECT id, password_hash FROM users WHERE email = $1")
.bind(&body.email)
.fetch_optional(&state.pool)
.await?;
// An unknown email is checked against a dummy hash, so both failures take similar time.
let (user_id, stored) = match row {
Some((id, hash)) => (Some(id), hash),
None => (None, state.dummy_hash.clone()),
};
let password_ok = password::verify_async(body.password, stored).await;
let Some(user_id) = user_id.filter(|_| password_ok) else {
return Err(ApiError::InvalidCredentials);
};
let token = new_token();
sqlx::query(
"INSERT INTO sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + interval '8 hours')",
)
.bind(hash_token(&token))
.bind(user_id)
.execute(&state.pool)
.await?;
Ok(Json(TokenDto { token }))
}src/main.rs
// src/main.rs
mod auth;
mod error;
mod password;
use axum::{
routing::post,
Router,
};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub dummy_hash: String,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.expect("database connection");
let dummy_hash = password::hash_async("not-a-real-password".to_owned())
.await
.expect("hash");
let app = Router::new()
.route("/register", post(auth::register))
.route("/login", post(auth::login))
.with_state(AppState { pool, dummy_hash });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Example 3: The authentication extractor
A type that implements FromRequestParts runs before the handler body. If it fails, the handler never runs. A handler that takes Principal requires a caller, and a handler without it is public. Axum 0.8 uses native async fn in this trait, so no #[async_trait] is needed.
The session lookup joins users, so the role is read from the database on every request. A role change takes effect immediately.
src/principal.rs
// src/principal.rs
use axum::{
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts},
Json,
};
use serde::Serialize;
use crate::{auth::hash_token, error::ApiError, AppState};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Customer,
Support,
Admin,
}
impl Role {
fn from_db(value: &str) -> Option<Role> {
match value {
"customer" => Some(Role::Customer),
"support" => Some(Role::Support),
"admin" => Some(Role::Admin),
_ => None,
}
}
}
/// The authenticated caller. Handlers that take a `Principal` require a valid session.
#[derive(Clone, Debug, Serialize)]
pub struct Principal {
pub user_id: i64,
pub tenant_id: i64,
pub role: Role,
}
impl FromRequestParts<AppState> for Principal {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let value = parts
.headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or(ApiError::Unauthorized)?;
let token = value.strip_prefix("Bearer ").ok_or(ApiError::Unauthorized)?;
let row: Option<(i64, i64, String)> = sqlx::query_as(
"SELECT u.id, u.tenant_id, u.role
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now()",
)
.bind(hash_token(token))
.fetch_optional(&state.pool)
.await?;
let (user_id, tenant_id, role) = row.ok_or(ApiError::Unauthorized)?;
let role = Role::from_db(&role).ok_or(ApiError::Internal)?;
Ok(Principal {
user_id,
tenant_id,
role,
})
}
}
pub async fn me(principal: Principal) -> Json<Principal> {
Json(principal)
}src/main.rs
// src/main.rs
mod auth;
mod error;
mod password;
mod principal;
use axum::{
routing::{get, post},
Router,
};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub dummy_hash: String,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.expect("database connection");
let dummy_hash = password::hash_async("not-a-real-password".to_owned())
.await
.expect("hash");
let app = Router::new()
.route("/register", post(auth::register))
.route("/login", post(auth::login))
.route("/me", get(principal::me))
.with_state(AppState { pool, dummy_hash });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}- Status 401 means "not authenticated". Status 403 means "authenticated, not allowed".
- A middleware layer built with
middleware::from_fn_with_stateand applied withroute_layercan require authentication for a group of routes (Tower middleware). The extractor keeps the requirement visible in each handler signature.
Example 4: Role checks
Role-based access control (RBAC) attaches permissions to roles and roles to principals. All rules are in one function, so the policy is not spread over handlers. The last arm denies every pair that is not listed, so a new action is forbidden until someone grants it.
RBAC cannot express "a customer reads only their own orders", because that depends on the data. Example 5 covers it.
src/policy.rs
// src/policy.rs
use crate::principal::Role;
#[derive(Clone, Copy, Debug)]
pub enum Action {
CreateOrder,
ReadOwnOrder,
ReadAnyOrder,
RefundOrder,
}
impl Role {
/// The whole role policy lives here. Any pair not listed is denied.
pub fn can(self, action: Action) -> bool {
match (self, action) {
(Role::Admin, _) => true,
(Role::Support, Action::ReadAnyOrder | Action::RefundOrder) => true,
(Role::Customer | Role::Support, Action::CreateOrder | Action::ReadOwnOrder) => true,
_ => false,
}
}
}Example 5: Ownership and tenant checks
Broken object-level authorization is a common API flaw: a caller changes an id in a URL and reads another user's record. The defense is to put the owner and tenant in the query and not to fetch first and compare afterwards.
src/orders.rs
// src/orders.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use crate::{error::ApiError, policy::Action, principal::Principal, AppState};
#[derive(Serialize, sqlx::FromRow)]
pub struct OrderDto {
id: i64,
tenant_id: i64,
customer_id: i64,
status: String,
total_cents: i64,
}
#[derive(Deserialize)]
pub struct NewOrder {
total_cents: i64,
}
pub async fn create(
principal: Principal,
State(state): State<AppState>,
Json(body): Json<NewOrder>,
) -> Result<(StatusCode, Json<OrderDto>), ApiError> {
if !principal.role.can(Action::CreateOrder) {
return Err(ApiError::Forbidden);
}
if body.total_cents <= 0 {
return Err(ApiError::Invalid("total must be positive"));
}
// Tenant and customer come from the session, never from the request body.
let order = sqlx::query_as::<_, OrderDto>(
"INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, customer_id, status, total_cents",
)
.bind(principal.tenant_id)
.bind(principal.user_id)
.bind(body.total_cents)
.fetch_one(&state.pool)
.await?;
Ok((StatusCode::CREATED, Json(order)))
}
pub async fn list_mine(
principal: Principal,
State(state): State<AppState>,
) -> Result<Json<Vec<OrderDto>>, ApiError> {
let orders = sqlx::query_as::<_, OrderDto>(
"SELECT id, tenant_id, customer_id, status, total_cents
FROM orders
WHERE tenant_id = $1 AND customer_id = $2
ORDER BY id",
)
.bind(principal.tenant_id)
.bind(principal.user_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(orders))
}
pub async fn get_one(
principal: Principal,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<OrderDto>, ApiError> {
// The tenant condition always applies. The owner condition is skipped only for roles
// that may read any order. A foreign or missing order gives the same 404.
let any = principal.role.can(Action::ReadAnyOrder);
let order = sqlx::query_as::<_, OrderDto>(
"SELECT id, tenant_id, customer_id, status, total_cents
FROM orders
WHERE id = $1 AND tenant_id = $2 AND ($4 OR customer_id = $3)",
)
.bind(id)
.bind(principal.tenant_id)
.bind(principal.user_id)
.bind(any)
.fetch_optional(&state.pool)
.await?;
order.map(Json).ok_or(ApiError::NotFound)
}
pub async fn refund(
principal: Principal,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<OrderDto>, ApiError> {
if !principal.role.can(Action::RefundOrder) {
return Err(ApiError::Forbidden);
}
let order = sqlx::query_as::<_, OrderDto>(
"UPDATE orders SET status = 'refunded'
WHERE id = $1 AND tenant_id = $2
RETURNING id, tenant_id, customer_id, status, total_cents",
)
.bind(id)
.bind(principal.tenant_id)
.fetch_optional(&state.pool)
.await?;
order.map(Json).ok_or(ApiError::NotFound)
}src/main.rs
// src/main.rs
mod auth;
mod error;
mod orders;
mod password;
mod policy;
mod principal;
use axum::{
routing::{get, post},
Router,
};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub dummy_hash: String,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.expect("database connection");
let dummy_hash = password::hash_async("not-a-real-password".to_owned())
.await
.expect("hash");
let app = Router::new()
.route("/register", post(auth::register))
.route("/login", post(auth::login))
.route("/me", get(principal::me))
.route("/orders", get(orders::list_mine).post(orders::create))
.route("/orders/{id}", get(orders::get_one))
.route("/orders/{id}/refund", post(orders::refund))
.with_state(AppState { pool, dummy_hash });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}- A foreign order and a missing order both return 404, so the response does not confirm the id exists.
- Hard-to-guess ids are not protection. Ids leak through logs and links, so the ownership check stays.
- Identity fields in a request body are ignored. The tenant and customer come from the session.
- The tenant filter is in every query. The composite foreign key in the schema is a second layer. PostgreSQL row-level security is a third layer that filters rows even when a query omits the condition. It needs the tenant set on each connection or transaction, for example with
SET LOCAL. With a pool, a missed setting leaves a connection unfiltered. - A service that calls another service is a principal too. It needs its own credential (a client-credentials token or an mTLS certificate). A request from inside the cluster is not authenticated by its network location.
Complete example
File tree:
storefront-auth/
Cargo.toml
migrations/0001_auth.sql
src/
main.rs
error.rs
password.rs
auth.rs
principal.rs
policy.rs
orders.rsCargo.toml
# Cargo.toml
[package]
name = "storefront-auth"
version = "0.1.0"
edition = "2021"
[dependencies]
argon2 = "0.5"
axum = "0.8"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }migrations/0001_auth.sql
-- migrations/0001_auth.sql
CREATE TABLE tenants (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
INSERT INTO tenants (name) VALUES ('acme'), ('globex');
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants (id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'customer'
CHECK (role IN ('customer', 'support', 'admin')),
UNIQUE (tenant_id, id)
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'placed',
total_cents BIGINT NOT NULL CHECK (total_cents > 0),
FOREIGN KEY (tenant_id, customer_id) REFERENCES users (tenant_id, id)
);src/main.rs
// src/main.rs
mod auth;
mod error;
mod orders;
mod password;
mod policy;
mod principal;
use axum::{
routing::{get, post},
Router,
};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub dummy_hash: String,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.expect("database connection");
let dummy_hash = password::hash_async("not-a-real-password".to_owned())
.await
.expect("hash");
let app = Router::new()
.route("/register", post(auth::register))
.route("/login", post(auth::login))
.route("/me", get(principal::me))
.route("/orders", get(orders::list_mine).post(orders::create))
.route("/orders/{id}", get(orders::get_one))
.route("/orders/{id}/refund", post(orders::refund))
.with_state(AppState { pool, dummy_hash });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}src/error.rs
// src/error.rs
use axum::{
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub enum ApiError {
Unauthorized,
InvalidCredentials,
Forbidden,
NotFound,
Conflict,
Invalid(&'static str),
Internal,
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
if let sqlx::Error::Database(db) = &err {
if db.is_unique_violation() {
return ApiError::Conflict;
}
if db.is_foreign_key_violation() {
return ApiError::Invalid("unknown reference");
}
}
eprintln!("database error: {err}");
ApiError::Internal
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::Unauthorized => {
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer")],
Json(json!({ "error": "unauthorized" })),
)
.into_response();
}
ApiError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid credentials"),
ApiError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"),
ApiError::Conflict => (StatusCode::CONFLICT, "conflict"),
ApiError::Invalid(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/password.rs
// src/password.rs
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
pub fn verify_password(password: &str, stored: &str) -> bool {
match PasswordHash::new(stored) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
/// Hashing is CPU heavy. `spawn_blocking` keeps it off the async worker threads.
pub async fn hash_async(password: String) -> Option<String> {
tokio::task::spawn_blocking(move || hash_password(&password).ok())
.await
.ok()
.flatten()
}
pub async fn verify_async(password: String, stored: String) -> bool {
tokio::task::spawn_blocking(move || verify_password(&password, &stored))
.await
.unwrap_or(false)
}src/auth.rs
// src/auth.rs
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{error::ApiError, password, AppState};
#[derive(Deserialize)]
pub struct Register {
email: String,
password: String,
tenant_id: i64,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct UserDto {
id: i64,
tenant_id: i64,
email: String,
role: String,
}
#[derive(Deserialize)]
pub struct Login {
email: String,
password: String,
}
#[derive(Serialize)]
pub struct TokenDto {
token: String,
}
/// A random session token: 244 random bits as 64 hex characters.
fn new_token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
/// The database stores only the SHA-256 of the token, so a database leak does not reveal sessions.
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
pub async fn register(
State(state): State<AppState>,
Json(body): Json<Register>,
) -> Result<(StatusCode, Json<UserDto>), ApiError> {
if !body.email.contains('@') {
return Err(ApiError::Invalid("invalid email"));
}
if body.password.chars().count() < 8 {
return Err(ApiError::Invalid("password needs at least 8 characters"));
}
let hash = password::hash_async(body.password)
.await
.ok_or(ApiError::Internal)?;
// The role is never taken from the request. New users are customers.
let user = sqlx::query_as::<_, UserDto>(
"INSERT INTO users (tenant_id, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, email, role",
)
.bind(body.tenant_id)
.bind(&body.email)
.bind(hash)
.fetch_one(&state.pool)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}
pub async fn login(
State(state): State<AppState>,
Json(body): Json<Login>,
) -> Result<Json<TokenDto>, ApiError> {
let row: Option<(i64, String)> =
sqlx::query_as("SELECT id, password_hash FROM users WHERE email = $1")
.bind(&body.email)
.fetch_optional(&state.pool)
.await?;
// An unknown email is checked against a dummy hash, so both failures take similar time.
let (user_id, stored) = match row {
Some((id, hash)) => (Some(id), hash),
None => (None, state.dummy_hash.clone()),
};
let password_ok = password::verify_async(body.password, stored).await;
let Some(user_id) = user_id.filter(|_| password_ok) else {
return Err(ApiError::InvalidCredentials);
};
let token = new_token();
sqlx::query(
"INSERT INTO sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + interval '8 hours')",
)
.bind(hash_token(&token))
.bind(user_id)
.execute(&state.pool)
.await?;
Ok(Json(TokenDto { token }))
}src/principal.rs
// src/principal.rs
use axum::{
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts},
Json,
};
use serde::Serialize;
use crate::{auth::hash_token, error::ApiError, AppState};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Customer,
Support,
Admin,
}
impl Role {
fn from_db(value: &str) -> Option<Role> {
match value {
"customer" => Some(Role::Customer),
"support" => Some(Role::Support),
"admin" => Some(Role::Admin),
_ => None,
}
}
}
/// The authenticated caller. Handlers that take a `Principal` require a valid session.
#[derive(Clone, Debug, Serialize)]
pub struct Principal {
pub user_id: i64,
pub tenant_id: i64,
pub role: Role,
}
impl FromRequestParts<AppState> for Principal {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let value = parts
.headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or(ApiError::Unauthorized)?;
let token = value.strip_prefix("Bearer ").ok_or(ApiError::Unauthorized)?;
let row: Option<(i64, i64, String)> = sqlx::query_as(
"SELECT u.id, u.tenant_id, u.role
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now()",
)
.bind(hash_token(token))
.fetch_optional(&state.pool)
.await?;
let (user_id, tenant_id, role) = row.ok_or(ApiError::Unauthorized)?;
let role = Role::from_db(&role).ok_or(ApiError::Internal)?;
Ok(Principal {
user_id,
tenant_id,
role,
})
}
}
pub async fn me(principal: Principal) -> Json<Principal> {
Json(principal)
}src/policy.rs
// src/policy.rs
use crate::principal::Role;
#[derive(Clone, Copy, Debug)]
pub enum Action {
CreateOrder,
ReadOwnOrder,
ReadAnyOrder,
RefundOrder,
}
impl Role {
/// The whole role policy lives here. Any pair not listed is denied.
pub fn can(self, action: Action) -> bool {
match (self, action) {
(Role::Admin, _) => true,
(Role::Support, Action::ReadAnyOrder | Action::RefundOrder) => true,
(Role::Customer | Role::Support, Action::CreateOrder | Action::ReadOwnOrder) => true,
_ => false,
}
}
}src/orders.rs
// src/orders.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use crate::{error::ApiError, policy::Action, principal::Principal, AppState};
#[derive(Serialize, sqlx::FromRow)]
pub struct OrderDto {
id: i64,
tenant_id: i64,
customer_id: i64,
status: String,
total_cents: i64,
}
#[derive(Deserialize)]
pub struct NewOrder {
total_cents: i64,
}
pub async fn create(
principal: Principal,
State(state): State<AppState>,
Json(body): Json<NewOrder>,
) -> Result<(StatusCode, Json<OrderDto>), ApiError> {
if !principal.role.can(Action::CreateOrder) {
return Err(ApiError::Forbidden);
}
if body.total_cents <= 0 {
return Err(ApiError::Invalid("total must be positive"));
}
// Tenant and customer come from the session, never from the request body.
let order = sqlx::query_as::<_, OrderDto>(
"INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, customer_id, status, total_cents",
)
.bind(principal.tenant_id)
.bind(principal.user_id)
.bind(body.total_cents)
.fetch_one(&state.pool)
.await?;
Ok((StatusCode::CREATED, Json(order)))
}
pub async fn list_mine(
principal: Principal,
State(state): State<AppState>,
) -> Result<Json<Vec<OrderDto>>, ApiError> {
let orders = sqlx::query_as::<_, OrderDto>(
"SELECT id, tenant_id, customer_id, status, total_cents
FROM orders
WHERE tenant_id = $1 AND customer_id = $2
ORDER BY id",
)
.bind(principal.tenant_id)
.bind(principal.user_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(orders))
}
pub async fn get_one(
principal: Principal,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<OrderDto>, ApiError> {
// The tenant condition always applies. The owner condition is skipped only for roles
// that may read any order. A foreign or missing order gives the same 404.
let any = principal.role.can(Action::ReadAnyOrder);
let order = sqlx::query_as::<_, OrderDto>(
"SELECT id, tenant_id, customer_id, status, total_cents
FROM orders
WHERE id = $1 AND tenant_id = $2 AND ($4 OR customer_id = $3)",
)
.bind(id)
.bind(principal.tenant_id)
.bind(principal.user_id)
.bind(any)
.fetch_optional(&state.pool)
.await?;
order.map(Json).ok_or(ApiError::NotFound)
}
pub async fn refund(
principal: Principal,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<OrderDto>, ApiError> {
if !principal.role.can(Action::RefundOrder) {
return Err(ApiError::Forbidden);
}
let order = sqlx::query_as::<_, OrderDto>(
"UPDATE orders SET status = 'refunded'
WHERE id = $1 AND tenant_id = $2
RETURNING id, tenant_id, customer_id, status, total_cents",
)
.bind(id)
.bind(principal.tenant_id)
.fetch_optional(&state.pool)
.await?;
order.map(Json).ok_or(ApiError::NotFound)
}Start PostgreSQL and run:
docker run --name pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=storefront -p 5432:5432 -d postgres:16
docker exec -i pg psql -U postgres -d storefront < migrations/0001_auth.sql
export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/storefrontcargo runRequests (the token values are random, so they are captured in variables; jq extracts the field). Argon2 is slow in debug builds, so cargo run --release shortens each register and login call:
req() { curl -s -w ' [%{http_code}]\n' "$@"; }
J='content-type: application/json'
login() {
curl -s -X POST localhost:3000/login -H "$J" \
-d "{\"email\":\"$1\",\"password\":\"correct horse\"}" | jq -r .token
}
req -X POST localhost:3000/register -H "$J" -d '{"email":"alice@acme.test","password":"correct horse","tenant_id":1}'
req -X POST localhost:3000/register -H "$J" -d '{"email":"bob@acme.test","password":"correct horse","tenant_id":1}'
req -X POST localhost:3000/register -H "$J" -d '{"email":"carol@globex.test","password":"correct horse","tenant_id":2}'
req -X POST localhost:3000/register -H "$J" -d '{"email":"sam@acme.test","password":"correct horse","tenant_id":1}'
docker exec pg psql -U postgres -d storefront -c "UPDATE users SET role = 'support' WHERE email = 'sam@acme.test'"
ALICE=$(login alice@acme.test); BOB=$(login bob@acme.test)
CAROL=$(login carol@globex.test); SAM=$(login sam@acme.test)
req -X POST localhost:3000/login -H "$J" -d '{"email":"alice@acme.test","password":"wrong password"}'
req localhost:3000/me -H "Authorization: Bearer $ALICE"
req localhost:3000/me
req -X POST localhost:3000/orders -H "$J" -H "Authorization: Bearer $ALICE" -d '{"total_cents":500}'
req localhost:3000/orders/1 -H "Authorization: Bearer $ALICE"
req localhost:3000/orders/1 -H "Authorization: Bearer $BOB"
req localhost:3000/orders/1 -H "Authorization: Bearer $CAROL"
req localhost:3000/orders/1 -H "Authorization: Bearer $SAM"
req -X POST localhost:3000/orders/1/refund -H "Authorization: Bearer $ALICE"
req -X POST localhost:3000/orders/1/refund -H "Authorization: Bearer $SAM"
req localhost:3000/orders -H "Authorization: Bearer $ALICE"Responses on a fresh database:
{"id":1,"tenant_id":1,"email":"alice@acme.test","role":"customer"} [201]
{"id":2,"tenant_id":1,"email":"bob@acme.test","role":"customer"} [201]
{"id":3,"tenant_id":2,"email":"carol@globex.test","role":"customer"} [201]
{"id":4,"tenant_id":1,"email":"sam@acme.test","role":"customer"} [201]
UPDATE 1
{"error":"invalid credentials"} [401]
{"user_id":1,"tenant_id":1,"role":"customer"} [200]
{"error":"unauthorized"} [401]
{"id":1,"tenant_id":1,"customer_id":1,"status":"placed","total_cents":500} [201]
{"id":1,"tenant_id":1,"customer_id":1,"status":"placed","total_cents":500} [200]
{"error":"not found"} [404]
{"error":"not found"} [404]
{"id":1,"tenant_id":1,"customer_id":1,"status":"placed","total_cents":500} [200]
{"error":"forbidden"} [403]
{"id":1,"tenant_id":1,"customer_id":1,"status":"refunded","total_cents":500} [200]
[{"id":1,"tenant_id":1,"customer_id":1,"status":"refunded","total_cents":500}] [200]Bob is in the same tenant and is not the owner, and Carol is in another tenant. Both get 404. Sam has the support role, which may read and refund any order in the same tenant.
Summary
- Authentication answers who is calling (401 on failure). Authorization answers what is allowed (403 on failure).
- Passwords are stored as Argon2id hashes in PHC format, computed with
spawn_blocking. - Login checks an unknown email against a dummy hash so failures look the same.
- Sessions store a hash of a random token, expire, and can be revoked by deleting the row.
- An extractor implementing
FromRequestPartsputs the authentication requirement in the handler signature. - RBAC maps roles to actions in one function and denies by default.
- Ownership and tenant conditions belong in the SQL
WHEREclause. Foreign objects return 404. - Identity and tenant come from the session, never from the request body.
