Auth and
Middleware
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.
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.
jsonwebtoken = "9" bcrypt = "0.15"
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") }
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.
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) }
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.
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) }
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.
Once a user is authenticated via AuthUser, some routes should additionally require a specific role. Create role-specific extractors that build on AuthUser:
/// 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 }
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.