How to handle CORS

Web
To handle CORS in Rust, you must explicitly configure your web server to send the correct `Access-Control-Allow-Origin` and related headers in response to browser requests.

The browser blocks your request before it reaches your code

You spin up your Rust API. It returns JSON perfectly when you test it with curl. You write a frontend that calls the endpoint. The network tab shows a 200 OK. The console screams Access to fetch has been blocked by CORS policy. Your server isn't broken. Your Rust code isn't broken. The browser is enforcing a security rule that has nothing to do with Rust and everything to do with how the web protects users from malicious sites.

CORS stands for Cross-Origin Resource Sharing. It is a browser-side security mechanism. The browser prevents a script running on frontend.com from reading data from api.com unless api.com explicitly grants permission. The server sends headers. The browser checks those headers. If the headers match the rules, the browser passes the data to your JavaScript. If not, the browser drops the response. Your code never sees the data. The network tab might show the response, but the browser hides it from the script.

Think of your API as a secure office building. The browser is the receptionist. By default, the receptionist turns away anyone from a different company. CORS is the server handing the receptionist a laminated list of approved visitor badges. Without that list, the receptionist blocks the door, even if the person inside is willing to talk. The server can't talk to the receptionist directly. The server has to display the list on the door for the receptionist to see. That list is the CORS headers.

Minimal setup with Actix-web

The most robust way to handle CORS in Rust is to use a middleware crate. Writing CORS logic by hand is error-prone. The actix-cors crate integrates with Actix-web and handles the complex header injection and preflight logic automatically.

Add actix-cors to your dependencies. Configure the middleware with allowed origins, methods, and headers. Wrap your app with the middleware. The middleware intercepts requests, checks the rules, and injects the necessary headers into responses.

use actix_web::{web, App, HttpServer, HttpResponse};
use actix_cors::Cors;

/// Returns a simple greeting.
async fn index() -> HttpResponse {
    HttpResponse::Ok().body("Hello from Rust!")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Configure CORS to allow requests from any origin for development.
    // In production, restrict this to your specific frontend domain.
    let cors = Cors::default()
        .allow_any_origin()
        .allow_any_method()
        .allow_any_header()
        .max_age(3600);

    HttpServer::new(move || {
        App::new()
            // Wrap the app with the CORS middleware.
            // This injects the necessary headers into every response.
            .wrap(cors)
            .route("/", web::get().to(index))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

The Cors::default() call creates a permissive configuration. The .allow_any_origin() method tells the middleware to accept requests from any domain. The .allow_any_method() and .allow_any_header() methods do the same for HTTP methods and headers. The .max_age(3600) method caches the preflight response for one hour. This reduces network overhead by telling the browser not to ask for permission again for a while.

Convention aside: The crate name is actix-cors, not cors. The ecosystem has multiple CORS crates for different frameworks. Using the framework-specific crate ensures compatibility with the router and middleware stack.

The browser is the gatekeeper. Your server holds the key.

The preflight dance

Simple GET requests often work without extra configuration. The browser sends the request. The server replies. If the Access-Control-Allow-Origin header is present, the browser allows the response. This is called a simple request.

Complex requests trigger a preflight check. A request is complex if it uses methods like POST or PUT, or if it sends custom headers like Content-Type: application/json. The browser pauses. It sends an OPTIONS request to the server before sending the actual request. This preflight request asks, "Is it okay to send a POST with JSON?"

The server must reply with specific headers. The response must include Access-Control-Allow-Methods listing the allowed methods. It must include Access-Control-Allow-Headers listing the allowed headers. It must include Access-Control-Allow-Origin. If the server ignores the OPTIONS request or returns the wrong headers, the browser never sends the actual POST. The browser blocks the request entirely.

The CORS middleware handles this automatically. It intercepts OPTIONS requests. It checks the configuration. It returns the correct headers. You don't need to write an OPTIONS handler. The middleware does the work.

The middleware intercepts the noise. Your handlers stay clean.

Realistic setup with Axum

Axum uses the tower-http crate for middleware. The cors feature in tower-http provides a CorsLayer that integrates with Axum's router. The configuration is similar to Actix-web but uses the tower layer system.

use axum::{routing::get, Router};
use tower_http::cors::{Any, CorsLayer};

/// Returns a JSON response to demonstrate content-type handling.
async fn hello() -> String {
    serde_json::json!({ "message": "Hello CORS!" }).to_string()
}

#[tokio::main]
async fn main() {
    // Production configuration: restrict origins and methods.
    let cors = CorsLayer::new()
        // Only allow requests from this specific frontend domain.
        .allow_origin("https://myapp.com".parse().unwrap())
        // Explicitly list allowed methods.
        .allow_methods([axum::http::Method::GET, axum::http::Method::POST])
        // Allow standard headers plus Authorization.
        .allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::AUTHORIZATION]);

    let app = Router::new()
        .route("/", get(hello))
        .layer(cors);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

The CorsLayer::new() creates a restrictive configuration. The .allow_origin() method takes a parsed origin. The .allow_methods() method takes a list of methods. The .allow_headers() method takes a list of headers. This configuration is safe for production. It only allows requests from https://myapp.com. It only allows GET and POST. It only allows Content-Type and Authorization headers.

Convention aside: The tower-http crate is the ecosystem standard for middleware in Axum. Using tower-http for CORS follows the convention and ensures compatibility with other tower layers like compression or tracing.

The credential trap

Wildcards break when credentials enter the picture. If your frontend sends cookies or sets Authorization headers, the browser requires a specific origin. You cannot use allow_any_origin() or Access-Control-Allow-Origin: * with credentials.

The browser checks the Access-Control-Allow-Origin header. If the header is * and the request includes credentials, the browser rejects the response. You must return the exact origin that made the request. The server needs to read the Origin header from the request and reflect it back in the response.

Most CORS middleware supports this via a dynamic origin function. The function receives the incoming origin and returns a boolean or the allowed origin. The middleware validates the origin against a whitelist and returns the matching origin.

use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
use actix_cors::Cors;

/// Validates the origin against a production whitelist.
fn is_allowed_origin(origin: &str) -> bool {
    origin == "https://myapp.com" || origin == "https://admin.myapp.com"
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let cors = Cors::default()
        // Use a function to validate origins dynamically.
        // This allows credentials while maintaining security.
        .allowed_origin_fn(|origin, _req_head| is_allowed_origin(origin.as_ref()))
        .supports_credentials()
        .allow_any_method()
        .allow_any_header();

    HttpServer::new(move || {
        App::new()
            .wrap(cors)
            .route("/", web::get().to(|| async { "Hello!" }))
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

The .allowed_origin_fn() method takes a closure. The closure receives the origin string. It returns true if the origin is allowed. The middleware reflects the origin in the response header. The .supports_credentials() method adds the Access-Control-Allow-Credentials: true header. This tells the browser that credentials are allowed.

Wildcards are a convenience that turns into a vulnerability the moment you add cookies.

Caching preflight responses

Preflight requests add latency. The browser sends an OPTIONS request. The server replies. Then the browser sends the actual request. This doubles the round-trip time. You can reduce this overhead by caching the preflight response.

The Access-Control-Max-Age header tells the browser how long to cache the preflight result. If the header is 3600, the browser caches the result for one hour. During that hour, the browser sends the actual request without a preflight check. This improves performance for complex requests.

The CORS middleware supports max-age configuration. Set a reasonable value. Too short, and you lose the benefit. Too long, and changes to your CORS policy take too long to propagate. One hour is a common default.

Caching preflight responses is a performance win. Set max-age and watch the network tab quiet down.

Pitfalls and security

CORS configuration is a common source of bugs and security issues. Misconfiguration can lead to data leakage or broken functionality.

Never use allow_any_origin() in production unless your API is truly public. If your API handles user data, restrict origins to your frontend domains. Allowing any origin exposes your API to cross-site request forgery attacks. Malicious sites can make requests to your API on behalf of logged-in users.

Explicitly list allowed methods and headers. Allowing any method or header can expose internal endpoints or allow attackers to probe your API. List only the methods and headers your frontend needs.

Handle errors gracefully. If the middleware rejects a request, it returns a 403 Forbidden response. Ensure your frontend handles this error. The browser console will show a CORS error. The network tab will show the 403 response. Log these errors on the server to detect misconfigurations.

Don't trust * in production. The browser will punish you when credentials enter the chat.

When to use what

Use actix-cors when you are building with Actix-web and want a battle-tested middleware that handles preflight logic without boilerplate. Use tower-http CORS when you are using Axum or any tower-based stack; it integrates seamlessly as a layer and follows the ecosystem convention. Use manual header injection only when you are writing a raw server with no framework and need absolute control over every byte, though this is rarely worth the maintenance cost. Reach for specific origin lists when your API handles sensitive data or cookies; wildcards are a security risk and break credential support.

Pick the middleware that matches your framework. Reinventing CORS is a trap that leads to subtle security bugs.

Where to go next