Part IV — The Web·Chapter 13

Auth and
Middleware

JWT authentication in Rust is not a framework feature — it is a custom Tower middleware that validates a bearer token, extracts the authenticated user, and passes it as a typed extension to your handlers. Understanding how to build this yourself teaches you more than any auth library could. You also build bcrypt password hashing and a login endpoint.
§ 13.1
JWT — What Is Actually Happening

A JSON Web Token is a base64url-encoded string in three parts: header.payload.signature, separated by dots. The header names the signing algorithm (HS256 for HMAC-SHA256). The payload contains your claims — user ID, email, roles, expiry. The signature is HMAC-SHA256(base64(header) + "." + base64(payload), secret). Without the secret, the signature cannot be forged. With the secret, any fields in the payload can be verified instantly without a database lookup on every request.

JWT for the Sprint NOC API

Why JWT works well here

The Zabbix webhook and the NOC wall screens are different clients. A Zabbix webhook posts alerts using a long-lived service token. The wall screen NOC dashboard authenticates with a short-lived user token. JWT lets both cases work with the same verification middleware — we just check the claims differ by role. Service tokens have role: "service", expire in 365 days, and can only write alerts. User tokens have role: "noc_operator", expire in 8 hours, and can read everything. The middleware enforces this at the router layer — no handler checks roles individually.

Cargo.toml additions for auth
jsonwebtoken = "9"
bcrypt       = "0.15"
src/auth.rs — JWT types and encoding
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use chrono::Utc;

/// The claims embedded inside every JWT token
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub:   String,   // user id or service name
    pub email: String,
    pub role:  String,   // "noc_operator" | "service" | "admin"
    pub exp:   i64,      // unix timestamp — jsonwebtoken checks this
    pub iat:   i64,      // issued at
}

pub fn encode_token(
    claims: Claims,
    secret: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
    encode(
        &Header::default(),  // HS256 algorithm
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
}

pub fn decode_token(
    token: &str,
    secret: &str,
) -> Result<Claims, jsonwebtoken::errors::Error> {
    let mut validation = Validation::new(Algorithm::HS256);
    validation.validate_exp = true;  // reject expired tokens

    let data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &validation,
    )?;
    Ok(data.claims)
}

/// Create a user token valid for 8 hours
pub fn user_token(user_id: &str, email: &str, secret: &str) -> String {
    let now = Utc::now().timestamp();
    encode_token(Claims {
        sub:   user_id.to_string(),
        email: email.to_string(),
        role:  "noc_operator".to_string(),
        iat:   now,
        exp:   now + 8 * 3600,
    }, secret).expect("token encoding failed")
}
§ 13.2
Auth Middleware — Custom Tower Layer

The cleanest pattern for JWT auth in Axum is a custom extractor: implement FromRequestParts for a AuthUser struct. Axum calls this extraction automatically when a handler declares an AuthUser argument. If the token is missing or invalid, the extractor returns an error response and the handler never runs. This keeps auth logic completely out of your business logic handlers.

src/auth.rs — custom AuthUser extractor
use axum::{
    async_trait,
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

/// Injected into handlers that require authentication
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id:    String,
    pub email: String,
    pub role:  String,
}

#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        // 1. Read the Authorization header
        let auth_header = parts
            .headers
            .get(axum::http::header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError::Missing)?;

        // 2. Expect "Bearer {token}"
        let token = auth_header
            .strip_prefix("Bearer ")
            .ok_or(AuthError::Malformed)?;

        // 3. Retrieve JWT secret from app extensions
        let secret = parts
            .extensions
            .get::<JwtSecret>()
            .expect("JwtSecret must be added as an extension");

        // 4. Decode and validate
        let claims = decode_token(token, &secret.0)
            .map_err(|_| AuthError::Invalid)?;

        Ok(AuthUser {
            id:    claims.sub,
            email: claims.email,
            role:  claims.role,
        })
    }
}

#[derive(Debug)]
pub enum AuthError { Missing, Malformed, Invalid }

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let (status, msg) = match self {
            AuthError::Missing  => (StatusCode::UNAUTHORIZED, "missing token"),
            AuthError::Malformed => (StatusCode::BAD_REQUEST,  "malformed token"),
            AuthError::Invalid  => (StatusCode::UNAUTHORIZED, "invalid or expired token"),
        };
        (status, Json(json!({"error": msg}))).into_response()
    }
}

// Handler using auth — compiler enforces the AuthUser is valid before calling
async fn list_alerts_authenticated(
    user: AuthUser,          // <-- auth runs before this handler body
    State(state): State<AppState>,
) -> Json<Vec<Alert>> {
    tracing::info!("Alerts requested by {} ({})", user.email, user.role);
    let alerts = list_alerts(&state.db, None, 50).await.unwrap_or_default();
    Json(alerts)
}
§ 13.3
Login Endpoint — bcrypt Password Hashing

Passwords must never be stored in plaintext or with reversible encryption. bcrypt is a slow, adaptive hashing algorithm — deliberately computationally expensive so brute-force attacks are infeasible. The cost factor (typically 12) controls how many hash rounds are performed. Increasing the cost by 1 doubles the work. Sprint NOC operators authenticate once per shift; the 300ms bcrypt verification time is imperceptible to them and crippling to an attacker.

src/handlers/auth.rs — login endpoint
use bcrypt::{hash, verify, DEFAULT_COST};
use serde::{Deserialize, Serialize};
use axum::{extract::State, Json, http::StatusCode};

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

#[derive(Serialize)]
pub struct LoginResponse {
    token:      String,
    expires_in: u32,  // seconds
}

pub async fn login(
    State(state): State<AppState>,
    Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, Json<serde_json::Value>)> {
    // Look up user by email
    let user = sqlx::query!(
        "SELECT id::text, email, password_hash, role FROM users WHERE email = $1",
        body.email
    )
    .fetch_optional(&state.db)
    .await
    .map_err(|_| auth_error("database error"))?
    .ok_or_else(|| auth_error("invalid credentials"))?;

    // Verify password — bcrypt verify returns bool
    // Even if user not found we still call verify to prevent timing attacks
    let valid = tokio::task::spawn_blocking(move || {
        verify(&body.password, &user.password_hash).unwrap_or(false)
    }).await.unwrap();

    if !valid {
        return Err(auth_error("invalid credentials"));
    }

    let token = user_token(&user.id, &user.email, &state.config.jwt_secret);
    Ok(Json(LoginResponse { token, expires_in: 8 * 3600 }))
}

fn auth_error(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
    (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": msg})))
}

// To hash a password during user creation:
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
    let password = password.to_string();
    // bcrypt is CPU-intensive — run in blocking thread pool
    let hashed = tokio::task::spawn_blocking(move || {
        hash(&password, DEFAULT_COST)  // cost=12 (~300ms on modern CPU)
    }).await??;
    Ok(hashed)
}
⚠ spawn_blocking for CPU Work

bcrypt is deliberately slow — it blocks the OS thread for ~300ms. Calling it directly in an async function would block the Tokio thread, preventing it from serving other requests. Always use tokio::task::spawn_blocking to run CPU-intensive work on a dedicated thread pool. The same applies to compression, encryption, and any compute-heavy operation.

§ 13.4
Role Guards — Type-Safe Permissions

Once a user is authenticated via AuthUser, some routes should additionally require a specific role. Create role-specific extractors that build on AuthUser:

role-specific extractors
/// Only allows users with role "admin"
pub struct AdminUser(pub AuthUser);

#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for AdminUser {
    type Rejection = AuthError;
    async fn from_request_parts(parts: &mut Parts, s: &S) -> Result<Self, AuthError> {
        let user = AuthUser::from_request_parts(parts, s).await?;
        if user.role != "admin" {
            return Err(AuthError::Invalid);
        }
        Ok(AdminUser(user))
    }
}

// Usage: this route is inaccessible to non-admins at the type level
async fn delete_site(
    _admin: AdminUser,  // 403 if not admin, before this handler runs
    Path(name): Path<String>,
    State(state): State<AppState>,
) -> StatusCode {
    StatusCode::NO_CONTENT
}
Exercise 13-A

Service Token for Zabbix Webhooks

Create a POST /internal/alerts endpoint that accepts Zabbix webhook payloads. Zabbix sends a different JSON format than your public API — map it to CreateAlert internally. Secure this endpoint with a ServiceUser extractor that only accepts tokens with role "service". Generate a long-lived service token (365 days) in your seed.rs file and log it to stdout on startup. Zabbix will store this in its webhook configuration.