M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 23 of 42

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.

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:

bash
cargo run
curl -si localhost:8080/posts -H 'origin: http://localhost:5500' | grep -ci access-control
text
0

No 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

bash
cargo add tower-http --features cors,set-header

tower-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:

rust
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).

bash
cargo run
curl -si localhost:8080/posts -H 'origin: http://localhost:5500' | grep -i access-control

Now our server answers with access-control-allow-origin: http://localhost:5500, exactly the origin we listed. A different website:

bash
curl -si localhost:8080/posts -H 'origin: http://evil.example' | grep -ci access-control
text
0

No 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 our Content-Type, never guess
  • X-Frame-Options: DENY: never show our pages inside a frame on another site
  • Referrer-Policy: no-referrer: don't send our URLs to other sites
rust
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.

bash
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:

bash
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; echo
text
401 401 401 401 401 401 401 401 401 401

Nothing stops them.

5. tower_governor

bash
cargo add tower_governor

tower_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:

rust
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));
rust
Router::new()
    // ... all other routes
    .merge(auth_routes)
    .with_state(repo)

6. Unable To Extract Key

Run, and the loop again:

bash
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; echo

500 every time! Look at the body:

bash
curl -s localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'
text
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:

rust
use std::net::SocketAddr;

axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();

7. 429 Too Many Requests

bash
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; echo
text
401 401 401 401 401 429 429 429 429 429

Five tries, then 429 Too Many Requests. Looking at one blocked answer in full:

bash
curl -i localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'
text
HTTP/1.1 429 Too Many Requests

and 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

rust
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 Any with credentials
  • Three security headers with SetResponseHeaderLayer::if_not_present
  • tower_governor limits 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 Requests with retry-after

Next episode: file uploads. Images for our posts, multipart forms, and 413 when a file is too big. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam