Rust Web Server with Axum 0.8: CORS, Security Headers and Rate Limiting with 429
November 3, 2026
Episode 23 of the Rust Backend series, where we build the backend of a blog step by step. After episode 22 users can log in with a password or with GitHub. Now our API is about to meet the real internet: web pages from other sites, and people who send thousands of requests. Today: CORS, security headers, and rate limiting. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project. The video builds on the episode 21 code; the GitHub login from episode 22 is optional, so its two routes are not in the code below (add them back if you followed it). New crates today: tower-http features cors and set-header, and tower_governor.
1. Origins and CORS
Imagine a web page on http://localhost:5500 that wants to read our posts from http://localhost:8080. An origin is scheme + host + port, so a different port is a different origin. The browser only shows our answer to that page if our server says yes, with Access-Control-* headers.
A browser sends an Origin header with such requests, so we can play the browser with curl:
cargo run
curl -si localhost:8080/posts -H 'origin: http://localhost:5500' | grep -ci access-control0No access-control headers: our server never said yes, so a real browser would hide the answer from the page.
curl itself doesn't care: CORS is a browser rule. It protects the people who use browsers, not our server. So CORS is never a replacement for authentication.
2. CorsLayer with an explicit list
cargo add tower-http --features cors,set-headertower-http's CorsLayer says yes, but only to the origins we list. Here, the page on port 5500, with the methods and headers our API uses:
use axum::http::{header, HeaderValue, Method};
use tower_http::cors::CorsLayer;
let cors = CorsLayer::new()
.allow_origin(HeaderValue::from_static("http://localhost:5500"))
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true);and at the end of the router, .layer(cors). allow_credentials(true) lets the page send our refresh cookie. Never combine that with allow_origin(Any): it would let every website use our users' logins (tower-http refuses that combination anyway).
cargo run
curl -si localhost:8080/posts -H 'origin: http://localhost:5500' | grep -i access-controlNow our server answers with access-control-allow-origin: http://localhost:5500, exactly the origin we listed. A different website:
curl -si localhost:8080/posts -H 'origin: http://evil.example' | grep -ci access-control0No header at all. The browser says no.
3. Security headers
Three headers tell the browser to be careful with our answers:
X-Content-Type-Options: nosniff: trust ourContent-Type, never guessX-Frame-Options: DENY: never show our pages inside a frame on another siteReferrer-Policy: no-referrer: don't send our URLs to other sites
use tower_http::set_header::SetResponseHeaderLayer;
.layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")))
.layer(SetResponseHeaderLayer::if_not_present(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")))
.layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")))if_not_present adds a header only if the handler did not set it itself.
cargo run
curl -si localhost:8080/posts | grep -iE 'x-content|x-frame|referrer'All three are there.
4. Brute force
Now rate limiting. Someone tries a thousand passwords on Ana's account, and each attempt is cheap for them. Ten in a row:
for i in $(seq 10); do curl -s -o /dev/null -w '%{http_code} ' localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'; done; echo401 401 401 401 401 401 401 401 401 401Nothing stops them.
5. tower_governor
cargo add tower_governortower_governor counts requests per IP address: a burst of 5 at once, then one more every 2 seconds. Only on the routes where someone can guess: sign up, login and refresh. They move into their own small router with the layer, and merge joins it to the main one:
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
let governor = Arc::new(GovernorConfigBuilder::default().per_second(2).burst_size(5).finish().unwrap());
let auth_routes = Router::new()
.route("/signup", post(handlers::signup::<R>))
.route("/login", post(handlers::login::<R>))
.route("/auth/refresh", post(handlers::refresh::<R>))
.layer(GovernorLayer::new(governor));Router::new()
// ... all other routes
.merge(auth_routes)
.with_state(repo)6. Unable To Extract Key
Run, and the loop again:
cargo run
for i in $(seq 10); do curl -s -o /dev/null -w '%{http_code} ' localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'; done; echo500 every time! Look at the body:
curl -s localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'Unable To Extract Key!The limiter needs the client's IP address as its key, and axum only provides it if we ask for it. We tell axum to keep the connection info:
use std::net::SocketAddr;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();7. 429 Too Many Requests
cargo run
for i in $(seq 10); do curl -s -o /dev/null -w '%{http_code} ' localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'; done; echo401 401 401 401 401 429 429 429 429 429Five tries, then 429 Too Many Requests. Looking at one blocked answer in full:
curl -i localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'HTTP/1.1 429 Too Many Requestsand a retry-after header says how many seconds to wait before the next try. A thousand guesses now take over half an hour instead of a few seconds.
One note: behind a reverse proxy, every request comes from the proxy's address, so everyone would share one limit. Then the key must come from a header like X-Forwarded-For, which only a trusted proxy may set (tower_governor has a SmartIpKeyExtractor for that).
The final code
src/main.rs
mod auth;
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;
use axum::{routing::{get, post}, Router};
use repo::{Repo, SqliteRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use axum::http::{header, HeaderValue, Method};
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use std::sync::Arc;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use std::net::SocketAddr;
pub fn app<R: Repo>(repo: R) -> Router {
let cors = CorsLayer::new()
.allow_origin(HeaderValue::from_static("http://localhost:5500"))
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true);
let governor = Arc::new(GovernorConfigBuilder::default().per_second(2).burst_size(5).finish().unwrap());
let auth_routes = Router::new()
.route("/signup", post(handlers::signup::<R>))
.route("/login", post(handlers::login::<R>))
.route("/auth/refresh", post(handlers::refresh::<R>))
.layer(GovernorLayer::new(governor));
Router::new()
.route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
.route(
"/posts/{id}",
get(handlers::get_post::<R>).put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
)
.route("/posts/{id}/comments", get(handlers::list_comments::<R>).post(handlers::add_comment::<R>))
.route("/search", get(handlers::search_posts::<R>))
.route("/posts/{id}/restore", post(handlers::restore_post::<R>))
.route("/me", get(handlers::me::<R>))
.route("/admin/users", get(handlers::list_users::<R>))
.route("/auth/logout", post(handlers::logout::<R>))
.merge(auth_routes)
.with_state(repo)
.layer(cors)
.layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")))
.layer(SetResponseHeaderLayer::if_not_present(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")))
.layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")))
.layer(TraceLayer::new_for_http())
}
#[tokio::main]
async fn main() {
let cfg = config::config();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let addr = format!("{}:{}", cfg.host, cfg.port);
let options = SqliteConnectOptions::from_str(&cfg.database_url)
.unwrap()
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let app = app(SqliteRepo { pool });
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
tracing::info!(%addr, "listening");
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}Recap
- CORS is a browser rule: list the allowed origins explicitly; never
Anywith credentials - Three security headers with
SetResponseHeaderLayer::if_not_present tower_governorlimits requests per IP: burst 5, then one every 2 seconds, on the guessable routes only- "Unable To Extract Key" means axum has no client address: serve with
into_make_service_with_connect_info::<SocketAddr>() 429 Too Many Requestswithretry-after
Next episode: file uploads. Images for our posts, multipart forms, and 413 when a file is too big. Watch the series on YouTube.