Updated Aug 27, 2026

12. Authentication

📋 Jump to Takeaways

🎁 JWT authentication in Rust: one crate, one extractor, and every protected handler gets auth for free with no code duplication.

jsonwebtoken

[dependencies]
jsonwebtoken = "9"

Token Structure

Define the JWT claims — the payload that gets signed:

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,    // subject — typically user ID
    pub exp: usize,     // expiration timestamp (Unix)
    pub iat: usize,     // issued at timestamp
}

Generating Tokens

use jsonwebtoken::{encode, Header, EncodingKey};
use std::time::{SystemTime, UNIX_EPOCH};

pub fn create_token(user_id: &str, secret: &str) -> Result<String, AppError> {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs() as usize;

    let claims = Claims {
        sub: user_id.to_string(),
        iat: now,
        exp: now + 60 * 60 * 24, // 24 hours
    };

    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .map_err(|_| AppError::Internal)
}

Verifying Tokens

use jsonwebtoken::{decode, DecodingKey, Validation};

pub fn verify_token(token: &str, secret: &str) -> Result<Claims, AppError> {
    decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )
    .map(|data| data.claims)
    .map_err(|_| AppError::Unauthorized)
}

Add Unauthorized to your AppError:

#[derive(Debug, Error)]
pub enum AppError {
    // ...existing variants...
    #[error("unauthorized")]
    Unauthorized,
}

// In IntoResponse:
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),

Auth Extractor

Build the extractor from Lesson 10's pattern — now with the real secret from state:

use axum::{extract::{FromRequestParts, State}, http::request::Parts};

pub struct AuthUser {
    pub user_id: String,
}

impl FromRequestParts<AppState> for AuthUser {
    type Rejection = AppError;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let token = parts
            .headers
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .ok_or(AppError::Unauthorized)?;

        let claims = verify_token(token, &state.config.jwt_secret)?;

        Ok(AuthUser { user_id: claims.sub })
    }
}

Add jwt_secret: String to your Config struct and .env:

JWT_SECRET=your-secret-key-change-in-production

Password Storage

The login endpoint needs a users table and a way to verify passwords. Create the migration:

-- migrations/20260101000001_create_users.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Define a User model and the query/verify functions:

// src/auth/store.rs
use sqlx::PgPool;
use crate::error::AppError;

#[derive(sqlx::FromRow)]
pub struct User {
    pub id: i64,
    pub username: String,
    pub password_hash: String,
}

pub async fn find_user_by_username(
    pool: &PgPool,
    username: &str,
) -> Result<Option<User>, AppError> {
    sqlx::query_as!(
        User,
        "SELECT id, username, password_hash FROM users WHERE username = $1",
        username
    )
    .fetch_optional(pool)
    .await
    .map_err(|e| AppError::Database(e.to_string()))
}

For password hashing, add argon2 to Cargo.toml:

argon2 = "0.5"
use argon2::{Argon2, PasswordHash, PasswordVerifier};

pub fn verify_password(password: &str, hash: &str) -> bool {
    let parsed = PasswordHash::new(hash).expect("invalid hash format");
    Argon2::default()
        .verify_password(password.as_bytes(), &parsed)
        .is_ok()
}

Use argon2::password_hash to hash passwords on registration — never store plaintext.

Login Endpoint

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

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

pub async fn login(
    State(state): State<AppState>,
    Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
    // verify credentials against database (simplified here)
    let user = find_user_by_username(&state.pool, &body.username)
        .await?
        .ok_or(AppError::Unauthorized)?;

    if !verify_password(&body.password, &user.password_hash) {
        return Err(AppError::Unauthorized);
    }

    let token = create_token(&user.id.to_string(), &state.config.jwt_secret)?;
    Ok(Json(LoginResponse { token }))
}

Protecting Routes

pub async fn delete_link(
    State(state): State<AppState>,
    AuthUser { user_id }: AuthUser, // 401 if missing or invalid token
    Path(code): Path<String>,
) -> Result<StatusCode, AppError> {
    delete_by_code(&state.pool, &code).await?;
    Ok(StatusCode::NO_CONTENT)
}

Any handler that declares AuthUser requires a valid token. No middleware configuration needed — the type system enforces it.

Key Takeaways

  • jsonwebtoken handles JWT encoding and decoding with Rust types
  • Claims are a plain struct — sub (subject), exp (expiration), iat (issued at)
  • The AuthUser extractor centralizes token verification — handlers just declare it as a parameter
  • Store JWT_SECRET in environment variables — never hardcode it
  • Validation::default() checks expiration automatically
  • Return 401 Unauthorized for missing tokens, invalid tokens, and wrong credentials — don't distinguish between them to avoid enumeration

🎁 A cargo kill or server reboot mid-request corrupts in-flight responses. Next up: graceful shutdown — draining requests before the process exits.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy