Authentication and Authorization: JWTs, Sessions, and Not Screwing It Up

Authentication is the part of your application where the mistakes are both easiest to make and most consequential. This chapter covers the patterns that work, explains why the naive implementations fail, and is direct about the trade-offs.

Passwords: Hash Them Correctly

The only acceptable password hashing algorithms for new code are Argon2, bcrypt, and scrypt. Do not use SHA-256. Do not use SHA-512. Do not use MD5. These are not password hashing algorithms; they're message digest functions designed to be fast. Fast is the enemy of password security.

Argon2id is the current recommendation. Use the argon2 crate:

[dependencies]
argon2 = "0.5"
password-hash = "0.5"
rand_core = { version = "0.6", features = ["getrandom"] }
#![allow(unused)]
fn main() {
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 argon2 = Argon2::default();
    let hash = argon2.hash_password(password.as_bytes(), &salt)?;
    Ok(hash.to_string())
}

pub fn verify_password(
    password: &str,
    hash: &str,
) -> Result<bool, argon2::password_hash::Error> {
    let parsed_hash = PasswordHash::new(hash)?;
    Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok())
}
}

Argon2 is intentionally slow — that's the feature. On modern hardware, a single hash might take 50-100ms with default parameters. That means you must run it in spawn_blocking:

#![allow(unused)]
fn main() {
use tokio::task;

pub async fn hash_password_async(password: String) -> Result<String, AppError> {
    task::spawn_blocking(move || hash_password(&password))
        .await
        .map_err(|_| AppError::Internal("Password hashing task panicked".into()))?
        .map_err(|_| AppError::Internal("Password hashing failed".into()))
}

pub async fn verify_password_async(password: String, hash: String) -> Result<bool, AppError> {
    task::spawn_blocking(move || verify_password(&password, &hash))
        .await
        .map_err(|_| AppError::Internal("Verification task panicked".into()))?
        .map_err(|_| AppError::Internal("Verification failed".into()))
}
}

Calling hash_password directly in an async handler will block the Tokio worker thread for the duration of the hash operation. With default Argon2 parameters, you'd cap your authentication throughput at roughly 10-20 requests/second per thread. This is the kind of bug that looks like an infrastructure problem until you profile it.

JSON Web Tokens

JWTs are widely used and widely misunderstood. A JWT is three base64-encoded pieces separated by dots: a header, a payload (claims), and a signature. The server creates the token and signs it. Clients present the token on subsequent requests. The server verifies the signature — if valid, the claims are trusted without a database lookup.

[dependencies]
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
#![allow(unused)]
fn main() {
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,   // Subject (user ID)
    pub exp: u64,      // Expiration timestamp
    pub iat: u64,      // Issued at
    pub jti: String,   // JWT ID (for revocation)
}

pub struct JwtConfig {
    encoding_key: EncodingKey,
    decoding_key: DecodingKey,
    expiration_seconds: u64,
}

impl JwtConfig {
    pub fn new(secret: &[u8], expiration_seconds: u64) -> Self {
        Self {
            encoding_key: EncodingKey::from_secret(secret),
            decoding_key: DecodingKey::from_secret(secret),
            expiration_seconds,
        }
    }

    pub fn issue(&self, user_id: &str) -> Result<String, jsonwebtoken::errors::Error> {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();

        let claims = Claims {
            sub: user_id.to_string(),
            exp: now + self.expiration_seconds,
            iat: now,
            jti: uuid::Uuid::new_v4().to_string(),
        };

        encode(&Header::default(), &claims, &self.encoding_key)
    }

    pub fn verify(&self, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
        let mut validation = Validation::default();
        validation.validate_exp = true; // Enforces expiration

        decode::<Claims>(token, &self.decoding_key, &validation)
            .map(|data| data.claims)
    }
}
}

The jti (JWT ID) field is important for revocation. JWTs are stateless by design, which means you can't "invalidate" one without either a blocklist or short expiration. A jti lets you maintain a blocklist of revoked tokens — useful for logout and password change scenarios.

JWT Extraction as Axum Middleware

Rather than extracting the JWT in every handler, create an extractor:

#![allow(unused)]
fn main() {
use axum::{
    async_trait,
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
    RequestPartsExt,
};
use axum_extra::{
    headers::{authorization::Bearer, Authorization},
    TypedHeader,
};

// Requires: axum-extra = { version = "0.9", features = ["typed-header"] }

#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
    pub user_id: String,
    pub claims: Claims,
}

#[async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
    S: Send + Sync,
    JwtConfig: axum::extract::FromRef<S>,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        let TypedHeader(Authorization(bearer)) = parts
            .extract::<TypedHeader<Authorization<Bearer>>>()
            .await
            .map_err(|_| (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header"))?;

        let jwt_config = JwtConfig::from_ref(state);

        let claims = jwt_config
            .verify(bearer.token())
            .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token"))?;

        Ok(AuthenticatedUser {
            user_id: claims.sub.clone(),
            claims,
        })
    }
}
}

Now any handler that needs authentication just includes AuthenticatedUser as an argument:

#![allow(unused)]
fn main() {
async fn get_current_user(
    auth: AuthenticatedUser,
    State(pool): State<PgPool>,
) -> Result<Json<User>, StatusCode> {
    get_user_by_id(&pool, &auth.user_id)
        .await
        .map(Json)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .ok_or(StatusCode::NOT_FOUND)
}
}

If the token is missing, expired, or invalid, Axum calls the Rejection before the handler runs. Clean, composable, and impossible to forget.

The Login Endpoint

Putting it together:

#![allow(unused)]
fn main() {
use axum::{
    extract::State,
    http::StatusCode,
    response::Json,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;

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

#[derive(Serialize)]
pub struct LoginResponse {
    access_token: String,
    token_type: String,
    expires_in: u64,
}

pub async fn login(
    State(pool): State<PgPool>,
    State(jwt): State<JwtConfig>,
    Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
    // Fetch user — always do this before password check
    // to prevent username enumeration via timing differences
    let user = sqlx::query!(
        "SELECT id, password_hash FROM users WHERE email = $1",
        payload.email
    )
    .fetch_optional(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Always verify a password hash, even if user not found.
    // This prevents timing-based user enumeration.
    let (user_id, stored_hash) = match user {
        Some(u) => (u.id.to_string(), u.password_hash),
        None => {
            // Verify against a dummy hash to burn consistent time
            let _ = verify_password_async(
                payload.password,
                "$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()
            ).await;
            return Err(StatusCode::UNAUTHORIZED);
        }
    };

    let password_valid = verify_password_async(payload.password, stored_hash)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if !password_valid {
        return Err(StatusCode::UNAUTHORIZED);
    }

    let token = jwt.issue(&user_id)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(LoginResponse {
        access_token: token,
        token_type: "Bearer".to_string(),
        expires_in: 3600,
    }))
}
}

The timing-equalization for unknown users is worth noting. If you return immediately when a user doesn't exist, attackers can enumerate valid email addresses by measuring response times. Always do the slow work (password verification) even on the failure path.

Role-Based Authorization

Once authentication works, authorization is a matter of attaching roles or permissions to your JWT claims and checking them:

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,
    pub exp: u64,
    pub iat: u64,
    pub jti: String,
    pub roles: Vec<String>,  // Add roles to claims
}

// A typed extractor that requires a specific role
pub struct RequireRole(pub AuthenticatedUser);

impl RequireRole {
    pub fn require(role: &str) -> impl Fn(AuthenticatedUser) -> Result<Self, (StatusCode, &'static str)> + Clone + '_ {
        move |auth: AuthenticatedUser| {
            if auth.claims.roles.contains(&role.to_string()) {
                Ok(RequireRole(auth))
            } else {
                Err((StatusCode::FORBIDDEN, "Insufficient permissions"))
            }
        }
    }
}

// Handler that requires admin role via middleware
async fn admin_action(
    auth: AuthenticatedUser,
) -> Result<Json<serde_json::Value>, StatusCode> {
    if !auth.claims.roles.contains(&"admin".to_string()) {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(Json(serde_json::json!({"status": "ok"})))
}
}

For more sophisticated authorization, look at extracting a Permission type from your claims and using FromRequestParts to check it — the same pattern as AuthenticatedUser but with a required permission parameter.

Sessions vs JWTs

JWTs are stateless. Sessions are stateful. This trade-off is real:

JWTsSessions
Database lookup per requestNoYes
RevocationBlocklist or short expiryDelete session row
Horizontal scalingEasy (no shared state needed)Requires shared session store (Redis)
Token size~500-1000 bytes~32 bytes (session ID)
Claims visibilityVisible to client (base64)Opaque

For APIs consumed by mobile or single-page apps, JWTs are the standard choice. For traditional web apps with server-rendered pages, sessions backed by a database or Redis are simpler to reason about.

If you implement sessions, use tower-sessions with a store backend. Don't roll your own session ID generation — use cryptographically secure random bytes, not sequential IDs.

What to Never Do

  • Never store plaintext passwords. Not even temporarily.
  • Never use symmetric HMAC secrets in environment variables without rotation. Rotate them. Have a plan for rotation.
  • Never log JWT tokens. They're credentials. Treat them like passwords.
  • Never trust JWT claims without signature verification. The base64 is not encryption; anyone can decode it.
  • Never set exp to a year from now. Short expiration + refresh tokens is the pattern. 15 minutes for access tokens, days for refresh tokens.
  • Never put sensitive data in JWT claims. They're visible to the client. Put a user ID, not an SSN.

The next chapter is about what happens when these — or anything else — goes wrong.