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-productionPassword 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
jsonwebtokenhandles JWT encoding and decoding with Rust types- Claims are a plain struct —
sub(subject),exp(expiration),iat(issued at) - The
AuthUserextractor centralizes token verification — handlers just declare it as a parameter - Store
JWT_SECRETin environment variables — never hardcode it Validation::default()checks expiration automatically- Return
401 Unauthorizedfor 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.