Authentication answers “who is this user?” Authorization answers “what can this user do?” The two concerns are separate. A user can be authenticated and still forbidden from accessing a resource.
Authorization is domain-dependent. An internal admin tool, a multi-tenant SaaS product, and a public content platform all need different models. There is no universal authorization framework worth adopting for every project. The Rust ecosystem reflects this: most production Axum applications build authorization with custom extractors rather than reaching for a policy engine. This section follows that approach, building on the AuthUser extractor from Authentication.
Adding roles to the user model
The simplest authorization model adds a role directly to the user record. This covers the majority of applications where users fall into a small number of categories with distinct access levels.
Add a migration:
CREATE TYPE user_role AS ENUM ('user', 'editor', 'admin');
ALTER TABLE users ADD COLUMN role user_role NOT NULL DEFAULT 'user';
A PostgreSQL enum constrains the value at the database level. New roles require a migration (ALTER TYPE user_role ADD VALUE 'moderator'), which is appropriate when roles change infrequently. If your roles change often or vary per deployment, use a TEXT column with application-level validation instead.
The corresponding Rust types:
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum Role {
User,
Editor,
Admin,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub email_confirmed_at: Option<OffsetDateTime>,
pub password_hash: String,
pub role: Role,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
sqlx::Type with type_name = "user_role" maps the Rust enum to the PostgreSQL enum. The rename_all = "lowercase" attribute matches the lowercase variants in the SQL definition.
Permission checking with extractors
Axum extractors are the natural place for authorization. They run before the handler body, they can reject requests early, and they make permission requirements visible in the handler’s function signature.
Role-based extractor
A generic extractor that requires a minimum role:
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
};
pub struct RequireRole<const ROLE: u8>(pub User);
impl<S: Send + Sync, const ROLE: u8> FromRequestParts<S> for RequireRole<ROLE> {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let AuthUser(user) = AuthUser::from_request_parts(parts, state)
.await
.map_err(|e| e.into_response())?;
if !user.role.has_at_least(ROLE) {
return Err(StatusCode::FORBIDDEN.into_response());
}
Ok(RequireRole(user))
}
}
The const generic approach is clean, but Rust does not yet support enum values as const generics. Use integer constants as a workaround:
impl Role {
const fn level(self) -> u8 {
match self {
Role::User => 0,
Role::Editor => 1,
Role::Admin => 2,
}
}
fn has_at_least(&self, required: u8) -> bool {
self.level() >= required
}
}
pub const EDITOR: u8 = 1;
pub const ADMIN: u8 = 2;
Handlers declare their required role in the signature:
async fn admin_dashboard(RequireRole<ADMIN>(user): RequireRole<ADMIN>) -> Markup {
html! {
h1 { "Admin dashboard" }
p { "Logged in as " (user.email) }
}
}
async fn edit_article(RequireRole<EDITOR>(user): RequireRole<EDITOR>) -> Markup {
// Editors and admins can reach this handler
html! { h1 { "Edit article" } }
}
A simpler alternative avoids const generics entirely. Define separate extractor types for each role:
pub struct RequireAdmin(pub User);
impl<S: Send + Sync> FromRequestParts<S> for RequireAdmin {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let AuthUser(user) = AuthUser::from_request_parts(parts, state)
.await
.map_err(|e| e.into_response())?;
if user.role != Role::Admin {
return Err(StatusCode::FORBIDDEN.into_response());
}
Ok(RequireAdmin(user))
}
}
This is more verbose when you have many roles, but each extractor is self-contained and easy to understand. For most applications with two or three roles, separate types are the better choice.
Resource ownership checks
Role-based checks are not enough when access depends on who owns a resource. An editor should edit their own articles but not someone else’s. This is resource-level authorization, and it belongs in the handler, not in an extractor, because the handler is where you load the resource.
async fn update_article(
AuthUser(user): AuthUser,
State(state): State<AppState>,
Path(article_id): Path<Uuid>,
Form(form): Form<ArticleForm>,
) -> Result<impl IntoResponse, StatusCode> {
let article = sqlx::query_as!(
Article,
"SELECT * FROM articles WHERE id = $1",
article_id
)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// Ownership check: author or admin
if article.author_id != user.id && user.role != Role::Admin {
return Err(StatusCode::FORBIDDEN);
}
// Proceed with update...
Ok(Redirect::to(&format!("/articles/{}", article_id)))
}
The pattern is straightforward: load the resource, check whether the user has access, proceed or reject. Resist the temptation to push this into middleware or an extractor. Resource-level checks depend on the specific resource being accessed, which makes them inherently handler-level logic.
Protecting route groups
For coarse-grained protection (all routes under /admin require an admin), apply the extractor as a route layer:
use axum::middleware;
let admin_routes = Router::new()
.route("/admin/dashboard", get(admin_dashboard))
.route("/admin/users", get(admin_users))
.route("/admin/settings", get(admin_settings).post(update_settings))
.route_layer(middleware::from_extractor::<RequireAdmin>());
let app = Router::new()
.merge(admin_routes)
.route("/", get(home))
.route("/articles", get(list_articles))
.layer(session_layer);
route_layer applies the extractor to all routes in the group. Any request to /admin/* that fails the admin check gets a 403 before the handler runs. The extractor still runs per-request, hitting the database each time, so the session-backed user lookup from AuthUser happens on every admin request.
For unauthenticated route groups mixed with authenticated ones, structure your router so the auth layer only wraps the routes that need it:
let public_routes = Router::new()
.route("/", get(home))
.route("/login", get(show_login).post(handle_login))
.route("/register", get(show_register).post(handle_register));
let protected_routes = Router::new()
.route("/dashboard", get(dashboard))
.route("/settings", get(settings).post(update_settings))
.route_layer(middleware::from_extractor::<AuthUser>());
let app = Router::new()
.merge(public_routes)
.merge(protected_routes)
.layer(session_layer);Returning meaningful errors
A bare StatusCode::FORBIDDEN is unhelpful to users. In an HDA application, return an HTML fragment that explains what went wrong:
use axum::response::{IntoResponse, Response};
pub enum AuthzError {
Unauthenticated,
Forbidden,
}
impl IntoResponse for AuthzError {
fn into_response(self) -> Response {
match self {
AuthzError::Unauthenticated => {
// Redirect to login with a return URL
Redirect::to("/login").into_response()
}
AuthzError::Forbidden => {
(StatusCode::FORBIDDEN, html! {
h1 { "Access denied" }
p { "You do not have permission to access this page." }
a href="/" { "Return to home" }
}).into_response()
}
}
}
}
Use AuthzError::Unauthenticated (not logged in) to redirect to login. Use AuthzError::Forbidden (logged in but insufficient permissions) to show a 403 page. The distinction matters: a 401 redirect invites the user to log in, while a 403 tells them their current account cannot access the resource.
Multi-tenancy authorization
When your application serves multiple tenants (organisations, teams, workspaces), authorization gains a tenant dimension. A user may be an admin in one organisation and a regular member in another.
The most common model for HDA applications is a shared database with a tenant_id column on tenant-scoped tables:
CREATE TABLE organisations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE memberships (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
organisation_id UUID NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
role user_role NOT NULL DEFAULT 'user',
PRIMARY KEY (user_id, organisation_id)
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organisation_id UUID NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
A memberships table maps users to organisations with a role per membership. This replaces the single role column on the user record with a per-tenant role.
Tenant-scoped extractor
Build an extractor that resolves the current tenant from the request (subdomain, path parameter, or header) and verifies the user’s membership:
pub struct TenantUser {
pub user: User,
pub organisation_id: Uuid,
pub role: Role,
}
impl<S: Send + Sync> FromRequestParts<S> for TenantUser {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let AuthUser(user) = AuthUser::from_request_parts(parts, state)
.await
.map_err(|e| e.into_response())?;
// Extract organisation ID from path, e.g. /org/:org_id/projects
let Path(org_id): Path<Uuid> = Path::from_request_parts(parts, state)
.await
.map_err(|_| StatusCode::BAD_REQUEST.into_response())?;
let pool = parts
.extensions
.get::<PgPool>()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR.into_response())?;
let membership = sqlx::query_as!(
Membership,
"SELECT role as \"role: Role\" FROM memberships \
WHERE user_id = $1 AND organisation_id = $2",
user.id,
org_id
)
.fetch_optional(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
.ok_or(StatusCode::FORBIDDEN.into_response())?;
Ok(TenantUser {
user,
organisation_id: org_id,
role: membership.role,
})
}
}
Every query in a tenant-scoped handler then filters by organisation_id:
async fn list_projects(
tenant: TenantUser,
State(state): State<AppState>,
) -> Markup {
let projects = sqlx::query_as!(
Project,
"SELECT * FROM projects WHERE organisation_id = $1 ORDER BY name",
tenant.organisation_id
)
.fetch_all(&state.db)
.await
.expect("query failed");
html! {
h1 { "Projects" }
ul {
@for project in &projects {
li { (project.name) }
}
}
}
}
The WHERE organisation_id = $1 clause is the tenant boundary. Miss it on a single query and you leak data across tenants. This is the fundamental weakness of application-level tenant isolation: it depends on every query being correct.
For the three database-level isolation models (shared database with tenant column, schema-per-tenant, database-per-tenant), shared database with a tenant column is the right starting point for most applications. Schema-per-tenant and database-per-tenant add operational complexity (per-tenant migrations, connection routing) that is only justified when regulatory requirements or enterprise customers demand stronger isolation guarantees.
PostgreSQL Row-Level Security
PostgreSQL Row-Level Security (RLS) enforces tenant isolation at the database level rather than relying on application code to include WHERE tenant_id = $1 on every query. The database rejects or filters rows that violate the policy, regardless of what the application sends.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (organisation_id = current_setting('app.organisation_id')::uuid);
Set the tenant context at the start of each request using SET LOCAL inside a transaction:
let mut tx = pool.begin().await?;
sqlx::query("SELECT set_config('app.organisation_id', $1, true)")
.bind(org_id.to_string())
.execute(&mut *tx)
.await?;
// Queries in this transaction are automatically filtered by tenant
let projects = sqlx::query_as!(Project, "SELECT * FROM projects")
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
The third argument to set_config (true) scopes the setting to the current transaction. When the transaction ends, the setting resets.
RLS is powerful but carries significant operational gotchas:
- Connection pool contamination. If you use
set_config(..., false)(session-scoped instead of transaction-local), the setting persists on the connection. When that connection returns to the pool and is reused by a different tenant, it carries the previous tenant’s context. Always usetruefor transaction-local scope. - Superuser and table owner bypass. PostgreSQL superusers and table owners skip RLS entirely. Your development database (often running as a superuser) silently ignores all policies. Use
ALTER TABLE ... FORCE ROW LEVEL SECURITYand test with a restricted role. - SQLx compile-time checking. SQLx’s
query!macros connect to the database during compilation. If that database has RLS enabled and the compile-time connection uses a restricted role, queries fail at build time. PointDATABASE_URLat a superuser role for compilation and use a restricted role at runtime. - PgBouncer. RLS with session variables does not work with PgBouncer in statement pooling mode. Use transaction pooling or session pooling.
For most applications, application-level WHERE clauses with a well-tested tenant-scoped extractor are simpler and sufficient. Consider RLS when you need defence-in-depth for sensitive data, or when compliance requirements demand database-enforced isolation.
When to reach for a policy engine
Custom extractors handle role-based checks well. They become unwieldy when authorization rules are complex, frequently changing, or need to be auditable separately from application code.
Signs you have outgrown custom extractors:
- Permissions depend on combinations of user attributes, resource attributes, and environmental conditions (time of day, IP range). This is attribute-based access control (ABAC), and encoding it in Rust conditionals becomes error-prone.
- Non-developers (compliance officers, product managers) need to review or modify access policies.
- You need an audit trail of policy changes separate from code deployments.
Two production-quality options exist in the Rust ecosystem:
- Cedar (by Amazon, 3.6M+ downloads) provides a purpose-built policy language for RBAC and ABAC. Policies are human-readable text files evaluated by the Cedar engine. No Axum-specific integration exists; wrap it in a service called from your extractors or handlers.
- Casbin (1M+ downloads) supports ACL, RBAC, and ABAC models through a configuration-driven approach. The
axum-casbincrate provides Axum middleware integration.
Both are well-maintained. Cedar has stronger backing and a more expressive policy language. Casbin has broader ecosystem support and a ready-made Axum integration. Evaluate both if you reach the point of needing one. Most HDA applications with a handful of roles and straightforward ownership rules will not.
Gotchas
Authorization checks on every code path. A handler that loads a resource and modifies it needs the authorization check between load and modify, not just at the route level. Route-level checks confirm the user’s role. Handler-level checks confirm access to the specific resource. Both are needed.
Forgetting to scope queries by tenant. In a multi-tenant application, every query that touches tenant data must include the tenant filter. A single unscoped query leaks data across tenants. Code review should treat a missing WHERE organisation_id = $1 with the same severity as a SQL injection.
Caching user roles. If you cache the user’s role (in the session, in memory), a role change by an admin does not take effect until the cache expires or the user logs out. For most applications, querying the database on each request through the extractor is fast enough and avoids stale-role bugs. If you do cache, keep the TTL short.
Confusing 401 and 403. Return 401 (Unauthorized) when the user is not authenticated, prompting a login. Return 403 (Forbidden) when the user is authenticated but lacks permission. Mixing these up confuses both users and API consumers.
Horizontal privilege escalation. A user modifies a URL parameter (changing /articles/123/edit to /articles/456/edit) and accesses another user’s resource. Role checks alone do not prevent this. Every handler that operates on a specific resource must verify that the authenticated user has access to that particular resource.