CORS blocks your frontend, not your code
You spin up your Rust backend. You fire a fetch request from your React app running on port 3000. The network tab shows a 200 OK, but the JavaScript throws an error. "Access to fetch at 'http://localhost:8000/api' from origin 'http://localhost:3000' has been blocked by CORS policy." The server worked. The data is there. The browser just refuses to hand it to your code.
This isn't a bug in your Rust code. It's the browser enforcing a security boundary called Cross-Origin Resource Sharing. The server needs to send specific headers to tell the browser, "I trust this frontend. Let it read the response." Without those headers, the browser intercepts the response and blocks it before your JavaScript ever sees the data.
Origins and the browser security model
Browsers treat different origins as separate security zones. An origin is the combination of protocol, domain, and port. http://localhost:3000 and http://localhost:8000 are different origins because the ports differ. http://example.com and https://example.com are different origins because the protocols differ.
By default, browsers block a script on one origin from reading data from another origin. This prevents a malicious site from stealing your bank account details by sending a request to your bank's API. If any website could read responses from any other website, the web would be unusable.
Think of origins like different office buildings with keycard access. Your frontend is a developer in Building A trying to mail a letter to Building B. Building B receives the letter, but the receptionist refuses to give the reply to the developer because they don't have the right clearance. CORS is the clearance system. The server has to explicitly say, "I trust developers from Building A. Give them the reply."
The server communicates this trust through HTTP headers. The most important header is Access-Control-Allow-Origin. If the browser sees this header in the response, and the value matches the frontend's origin, the browser releases the data to the JavaScript. If the header is missing or the value doesn't match, the browser blocks the response.
The minimal setup
The Rust web ecosystem has converged on tower middleware for cross-cutting concerns. The tower-http crate provides a CorsLayer that handles all the header logic for you. You don't need to write manual header manipulation code.
Add tower-http to your Cargo.toml. The crate works with any framework that supports tower services, including Axum, Warp, and Hyper.
[dependencies]
tower-http = { version = "0.5", features = ["cors"] }
tower = "0.5"
Create the layer and wrap your service. For local development, you can use Any to allow all origins, methods, and headers.
use tower_http::cors::{Any, CorsLayer};
use tower::ServiceBuilder;
// Create the CORS layer with permissive settings for development.
let cors = CorsLayer::new()
.allow_origin(Any) // Allow requests from any origin.
.allow_methods(Any) // Allow GET, POST, OPTIONS, etc.
.allow_headers(Any); // Allow any headers, including Authorization.
// Wrap your service with the CORS layer.
// The layer intercepts requests and adds the necessary headers.
let app = ServiceBuilder::new()
.layer(cors)
.service(your_service);
The CorsLayer inspects incoming requests. If a request comes from a different origin, the layer adds the Access-Control-Allow-Origin header to the response. If the request is a preflight OPTIONS request, the layer generates a response with the allowed methods and headers. You don't need to handle OPTIONS manually.
Convention aside: The community standard is to use tower-http for CORS. Older crates like actix-cors or framework-specific filters exist, but tower-http keeps your code portable. If you switch from Axum to Warp, the CORS layer stays the same. Stick to tower-http to avoid locking yourself into a single framework.
How the handshake works
Not every cross-origin request triggers the full CORS machinery. Browsers distinguish between simple requests and preflighted requests.
Simple requests use GET, HEAD, or POST with specific content types like text/plain, multipart/form-data, or application/x-www-form-urlencoded. They don't include custom headers. For simple requests, the browser sends the request immediately. The server responds with the Access-Control-Allow-Origin header. The browser checks the header and releases the data if it matches.
Preflighted requests use other methods like PUT, DELETE, or PATCH. They might include custom headers like X-Custom-Header or use application/json. Before sending the actual request, the browser sends an OPTIONS request to the server. This preflight asks, "Can I send a real request with these methods and headers?"
The server responds to the preflight with headers like Access-Control-Allow-Methods and Access-Control-Allow-Headers. If the browser sees that the requested method and headers are allowed, it sends the actual request. If the preflight fails, the browser never sends the real request.
The CorsLayer handles both cases. For simple requests, it adds the origin header to the response. For preflighted requests, it intercepts the OPTIONS request and returns the allowed configuration. You don't need to write separate handlers for OPTIONS.
Convention aside: Preflight requests are cached by browsers. The Access-Control-Max-Age header tells the browser how long to cache the preflight result. If you change your CORS configuration, you might need to hard-refresh the browser or clear the cache to see the changes. The CorsLayer sets a default max age, but you can override it with .max_age().
Real-world integration with Axum
Axum is a common choice for Rust web applications. The CorsLayer integrates seamlessly with Axum's router. You can apply the layer to the entire router or to specific routes.
use axum::{Router, routing::get};
use tower_http::cors::{Any, CorsLayer};
#[tokio::main]
async fn main() {
// Define the CORS policy.
// In production, replace Any with specific origins.
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Build the router.
let app = Router::new()
.route("/api/data", get(handler))
.layer(cors); // Apply CORS to all routes in the router.
// Start the server.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn handler() -> &'static str {
"Hello from Rust!"
}
The layer applies to all routes in the router. If you need different CORS policies for different routes, you can nest routers. Apply one layer to a sub-router and a different layer to the main router.
let public_app = Router::new()
.route("/public", get(public_handler))
.layer(CorsLayer::new().allow_origin(Any));
let private_app = Router::new()
.route("/private", get(private_handler))
.layer(CorsLayer::new().allow_origin("https://my-app.com".parse().unwrap()));
let app = Router::new()
.nest("/api", public_app)
.nest("/admin", private_app);
This structure lets you expose public endpoints to any origin while restricting admin endpoints to a specific domain. The router matches the path and applies the appropriate layer.
Pitfalls and browser behavior
CORS errors often confuse developers because the server returns a 200 OK, but the frontend gets nothing. The browser blocks the response after receiving it. The error appears in the browser console, not in your Rust logs. Check the console first.
One common trap involves credentials. If your frontend sends cookies or authorization headers, the browser requires the server to respond with Access-Control-Allow-Credentials: true. When credentials are involved, the server cannot use a wildcard * for the origin. It must echo the specific origin back in the Access-Control-Allow-Origin header.
If you use allow_origin(Any) and enable credentials, the browser blocks the request. The CorsLayer detects this conflict and rejects the request with a 400 error. You get a compiler error if you try to combine Any with credentials in a way that violates the rules. If you pass the wrong type to allow_origin, you get E0308 (mismatched types). The API requires Any, a HeaderValue, or a validator function.
To support credentials, you need to validate the origin and echo it back. Use a validator function for this.
use tower_http::cors::CorsLayer;
use http::HeaderValue;
let cors = CorsLayer::new()
.allow_origin(
// Validate the origin against a list of allowed domains.
// Echo the origin back to support credentials.
|origin: _, req: _| {
if origin.as_bytes() == b"https://my-app.com" {
Some(origin)
} else {
None
}
}
)
.allow_credentials(true) // Enable credentials support.
.allow_methods(Any)
.allow_headers(Any);
The validator function receives the Origin header from the request. It returns Some(origin) if the origin is allowed, or None if it's not. The CorsLayer uses the returned origin to set the Access-Control-Allow-Origin header. This satisfies the browser's requirement for credentials.
Another pitfall is exposing headers. By default, browsers only expose a set of safe headers to JavaScript. If your API returns custom headers like X-Rate-Limit or X-Request-Id, the frontend can't read them unless you expose them. Use expose_headers to whitelist headers.
let cors = CorsLayer::new()
.allow_origin(Any)
.expose_headers(["X-Rate-Limit", "X-Request-Id"]); // Make custom headers visible to JS.
Without this, the headers exist in the response, but response.headers.get("X-Rate-Limit") returns null. The browser hides them for security.
Don't fight the browser here. If the console says CORS, the headers are missing or wrong. Check the network tab to see what the server actually sent.
Choosing your CORS strategy
CORS configuration depends on your deployment environment and security requirements. Use the right strategy for your situation.
Use CorsLayer::new().allow_origin(Any) when you are prototyping locally and need to connect a frontend on a different port without configuring domains. This is safe for development but dangerous for production.
Use allow_origin(Origin::from_str("https://my-app.com").unwrap()) when you are deploying to production and want to restrict access to your specific frontend domain. This prevents other sites from making requests to your API.
Use allow_origin(validator) when you have multiple subdomains or dynamic origins and need to validate the request header at runtime. The validator function lets you check the origin against a database or configuration list.
Use allow_credentials(true) when your frontend needs to send cookies or authorization headers with the request. Remember that credentials require a specific origin, not a wildcard.
Use expose_headers() when your frontend needs to read custom response headers like rate limits or request IDs. The browser hides custom headers by default.
Use max_age() when you want to control how long browsers cache preflight results. A longer max age reduces the number of preflight requests but delays configuration changes.
Trust the borrow checker. It usually has a point. The same applies to CORS. The browser blocks requests for a reason. Configure the headers correctly, and the data flows.