Hook: Why authentication matters in production
You've built a solid Rust API that handles concurrent requests efficiently and scales across multiple instances. Then someone exploits a missing authentication check and your entire user database leaks. Authentication in Rust APIs: what developers should know isn't optional—it's foundational. Whether you're protecting customer data in an e-commerce platform or securing trading endpoints, getting authentication right from the start prevents costly incidents and keeps your team sleeping at night.
Understanding JWT vs. sessions
The first decision you'll make is whether to use JSON Web Tokens (JWT) or traditional server-side sessions. Both are valid, and the choice depends on your architecture and team preferences.
JWT tokens are stateless. The server signs a token containing claims (user ID, roles, permissions), sends it to the client, and doesn't store anything. The client includes this token with every request, and the server verifies the signature to confirm it hasn't been tampered with. This scales well across multiple server instances—no session synchronization needed.
Sessions are stateful. The server creates a session record, stores it in Redis or a database, and sends the client an opaque session ID. Each request requires a lookup to verify the session still exists and hasn't expired. Sessions require shared storage but offer simpler revocation—delete the session, and the token is immediately invalid.
In our experience, most modern Rust APIs use JWT for stateless microservices and hybrid approaches for monoliths. We'll focus on JWT because it's what you'll see in production at scale.
The jsonwebtoken crate for JWT implementation
The jsonwebtoken crate is the de facto standard in Rust. It handles encoding, decoding, and validation with minimal boilerplate.
Start by adding it to your Cargo.toml:
[dependencies]
jsonwebtoken = "9.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"Define your claims struct. Claims are the payload inside the JWT—data the server trusts because it signed them:
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use serde::{Deserialize, Serialize};
use chrono::{Utc, Duration};
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // subject (user ID)
pub user_id: i64,
pub email: String,
pub exp: i64, // expiration time (Unix timestamp)
pub iat: i64, // issued at
}
pub fn create_jwt(user_id: i64, email: String, secret: &[u8]) -> Result {
let now = Utc::now();
let expires_in = Duration::hours(1);
let exp = (now + expires_in).timestamp();
let claims = Claims {
sub: user_id.to_string(),
user_id,
email,
exp,
iat: now.timestamp(),
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret),
)
}
pub fn verify_jwt(token: &str, secret: &[u8]) -> Result {
decode::(
token,
&DecodingKey::from_secret(secret),
&Validation::default(),
)
.map(|data| data.claims)
}This gives you encoding and decoding. The exp claim is critical—it tells the JWT library when to reject the token. The iat claim records when it was issued, useful for audit trails.
Middleware extracts and validates claims
Raw JWT decoding is just one piece. In a real API, you need middleware that extracts the token from requests and makes claims available to your route handlers.
If you're using Actix-web, the community provides actix-jwt-style patterns. Here's how to build it:
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage, HttpResponse,
};
use futures_util::future::LocalBoxFuture;
use std::rc::Rc;
pub struct AuthMiddleware;
impl Transform for AuthMiddleware
where
S: Service, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<b>;
type Error = Error;
type InitError = ();
type Transform = AuthMiddlewareService<s>;
type Future = std::future::Ready>;
fn new_transform(&self, service: S) -> Self::Future {
std::future::ready(Ok(AuthMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct AuthMiddlewareService<s> {
service: Rc<s>,
}
impl Service for AuthMiddlewareService<s>
where
S: Service, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<b>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = self.service.clone();
Box::pin(async move {
let secret = b"your-secret-key"; // Use env var in production
// Extract "Bearer " from Authorization header
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(|s| s.to_string());
match auth_header {
Some(token) => {
match verify_jwt(&token, secret) {
Ok(claims) => {
// Store claims in request extensions
req.extensions_mut().insert(claims);
service.call(req).await
}
Err(_) => {
Err(actix_web::error::ErrorUnauthorized("Invalid token"))
}
}
}
None => {
Err(actix_web::error::ErrorUnauthorized("Missing token"))
}
}
})
}
}</b></s></s></s></s></b>Now your route handlers can extract claims from the request context:
use actix_web::{web, HttpRequest, HttpResponse};
async fn protected_route(req: HttpRequest) -> HttpResponse {
let claims = req
.extensions()
.get::()
.cloned()
.expect("Claims should be present");
HttpResponse::Ok().json(serde_json::json!({
"message": format!("Hello, user {}", claims.user_id)
}))
}This pattern ensures every protected endpoint has access to validated claims without repeating verification logic.
Hashing passwords with Argon2
Passwords must never be stored in plaintext. Argon2 is a modern password hashing algorithm designed to resist GPU attacks. The argon2 crate makes it straightforward:
[dependencies]
argon2 = "0.5"
rand = "0.8"When a user signs up, hash their password:
use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::SaltString;
use rand::rngs::OsRng;
pub fn hash_password(password: &str) -> Result {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(password_hash)
}When logging in, verify the provided password against the stored hash:
use argon2::PasswordHash;
use argon2::PasswordVerifier;
pub fn verify_password(password: &str, hash: &str) -> Result {
let parsed_hash = PasswordHash::new(hash)?;
let argon2 = Argon2::default();
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
Ok(_) => Ok(true),
Err(argon2::password_hash::Error::Password) => Ok(false),
Err(e) => Err(e),
}
}Argon2 is intentionally slow—it takes several hundred milliseconds to hash a password, which makes brute-force attacks impractical. Store this hash in your database, never the plaintext password.
Short-lived tokens for production security
A common mistake is issuing tokens that last days or weeks. If a token is stolen (e.g., via XSS or network interception), the attacker has unlimited time to use it. Short-lived tokens reduce that window significantly.
Recommended pattern:
- Access token: valid for 15 minutes to 1 hour.
- Refresh token: valid for 7 days, stored securely (httpOnly cookie for web clients).
When the access token expires, the client uses the refresh token to get a new access token without re-entering credentials:
pub fn create_tokens(user_id: i64, email: String, secret: &[u8]) -> Result<(String, String), Box> {
let now = Utc::now();
// Short-lived access token
let access_claims = Claims {
sub: user_id.to_string(),
user_id,
email: email.clone(),
exp: (now + Duration::minutes(15)).timestamp(),
iat: now.timestamp(),
};
let access_token = encode(
&Header::default(),
&access_claims,
&EncodingKey::from_secret(secret),
)?;
// Longer-lived refresh token
let refresh_claims = Claims {
sub: user_id.to_string(),
user_id,
email,
exp: (now + Duration::days(7)).timestamp(),
iat: now.timestamp(),
};
let refresh_token = encode(
&Header::default(),
&refresh_claims,
&EncodingKey::from_secret(secret),
)?;
Ok((access_token, refresh_token))
}Store the refresh token in an httpOnly cookie (inaccessible to JavaScript) or in secure device storage for mobile apps. Rotate refresh tokens on use—after the client exchanges it for a new access token, issue a fresh refresh token too. This prevents token reuse if a refresh token is compromised.
Common mistakes we've seen in production
Storing secrets in code: Never hardcode JWT secrets or database credentials. Use environment variables or a secrets manager like AWS Secrets Manager. Load them at startup and never log them.
Skipping token expiration: Tokens without expiration are effectively permanent credentials. Always set the exp claim and validate it on every request.
Trusting client-provided user ID: If a user sends a JWT with user_id: 999, verify that this user_id matches the token's subject claim. Don't extract user_id from query parameters and assume it's the authenticated user.
Using weak secrets: Your JWT secret must be long and random. At minimum, use a 32-byte key derived from a strong source. The jsonwebtoken crate won't stop you from using short secrets, but attackers will break them quickly.
Forgetting to validate signature algorithms: Always explicitly specify which algorithms you accept. Attackers have exploited "none" algorithm support in poorly configured JWT libraries. The Rust ecosystem is safer here, but be explicit in your Validation config.
Mixing authentication and authorization: Authentication answers "who are you?" (verified by JWT). Authorization answers "are you allowed to do this?" (checked in your business logic). Don't assume a valid JWT means the user can perform an action—validate permissions in your handlers.
Key takeaways
- Use
jsonwebtokenfor JWT encoding and decoding; it handles edge cases and validation transparently. - Build middleware that extracts tokens, verifies signatures, and makes claims available to handlers—don't repeat this logic in every route.
- Hash passwords with Argon2, never store plaintext. Verify against the hash during login.
- Issue short-lived access tokens (15 minutes to 1 hour) and longer-lived refresh tokens (days). Rotate refresh tokens on use.
- Load secrets from environment variables or a secrets manager, never hardcode them.
- Always set and validate the
expclaim. Implement token blacklisting for early revocation if needed. - Keep authentication and authorization concerns separate—verify identity via JWT, then check permissions in handlers.






Comments
Loading comments…