CORS in Actix-web is one of those things that looks trivial until your frontend starts failing with mysterious preflight errors at 2 AM.
Rust gives you strong guarantees around memory safety. CORS gives you sharp edges around browser behavior. Different problem space entirely.
If you’re building APIs with Actix-web, you’ll usually end up choosing between a few practical CORS strategies:
*for public APIs- strict allowlists for browser apps
- dynamic origin handling for multi-tenant setups
- “just reflect the origin” hacks you probably shouldn’t ship
I’ll compare those approaches, show where Actix-web fits well, and point out the tradeoffs that actually matter in production.
The Actix-web way: middleware-driven CORS
In Actix-web, CORS is generally configured with the actix-cors crate as middleware.
A basic setup looks like this:
use actix_web::{App, HttpServer};
use actix_cors::Cors;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header();
App::new()
.wrap(cors)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
That’s easy, but “easy” and “correct for your app” are not the same thing.
Option 1: Wildcard CORS for public APIs
This is the simplest model: allow every origin.
use actix_cors::Cors;
use actix_web::{App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().wrap(
Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Pros
- Very easy to configure
- Good fit for genuinely public APIs
- Fewer browser-side surprises
- Works well when you do not rely on cookies or authenticated browser sessions
Cons
- Not compatible with credentialed requests in the browser
- Broadens who can read your API from frontend JavaScript
- Easy to overuse because it “fixes” local dev quickly
This pattern is common for public, token-based APIs. GitHub is a good real-world example of a permissive CORS style for public access:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That’s a solid example of a public API exposing useful response metadata to browser clients. The access-control-expose-headers part matters more than many teams realize. Without it, frontend code can’t read non-simple headers like ETag or rate-limit fields.
If your API is public and stateless, wildcard CORS is often fine.
If your API uses cookies, stop here. Don’t use *.
Option 2: Explicit allowlist for known frontends
This is the default production choice for most internal apps, dashboards, SPAs, and B2B products.
use actix_cors::Cors;
use actix_web::{http, App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let cors = Cors::default()
.allowed_origin("https://app.example.com")
.allowed_origin("https://admin.example.com")
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
.allowed_headers(vec![http::header::AUTHORIZATION, http::header::CONTENT_TYPE])
.expose_headers(vec!["ETag", "Link", "X-Request-Id"])
.max_age(3600);
App::new().wrap(cors)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Pros
- Predictable and easy to audit
- Best fit for credentialed browser requests
- Reduces accidental data exposure to random origins
- Works nicely with environment-based config
Cons
- More setup overhead across environments
- Can get annoying with preview deployments
- Easy to break local development if you forget to allow localhost
This is the pattern I trust most. It forces you to be explicit.
If you use cookies or session auth, combine an allowlist with credentials support:
use actix_cors::Cors;
use actix_web::{http, App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
let cors = Cors::default()
.allowed_origin("https://app.example.com")
.allowed_methods(vec!["GET", "POST"])
.allowed_headers(vec![http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
.supports_credentials();
App::new().wrap(cors)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Browser rule: Access-Control-Allow-Credentials: true and Access-Control-Allow-Origin: * do not mix. If you need credentials, return a specific origin.
That’s not an Actix-web quirk. That’s the browser enforcing the spec.
Option 3: Dynamic origin validation
This comes up in SaaS products where every customer has a custom domain, or when you support multiple controlled subdomains.
A static allowlist gets messy fast. Dynamic checks can help, but they need discipline.
In practice, your policy might be:
- allow
https://*.customer.example.com - allow origins stored in a tenant config table
- deny everything else
The upside is flexibility. The downside is complexity and the risk of turning “dynamic” into “basically allow-anything.”
You should validate exact schemes, exact hosts, and preferably exact ports where relevant. Avoid weak substring matching like this:
// bad idea
if origin.contains("example.com") {
// attacker-example.com also matches
}
That bug shows up more often than people admit.
Pros
- Good fit for multi-tenant apps
- Can support custom domains cleanly
- Avoids huge hardcoded allowlists
Cons
- Harder to reason about
- Easier to get validation wrong
- Needs careful testing for origin parsing edge cases
If you go dynamic, make the policy as strict as your business model allows.
Option 4: Origin reflection
This is the “if there’s an Origin header, echo it back” approach.
Some teams do this because it makes every frontend “just work.” That convenience is exactly why I don’t like it.
If you reflect arbitrary origins and also allow credentials, you’ve effectively granted cross-origin browser read access to any site that can get a user’s browser to send a request. That’s a bad day.
Pros
- Minimal friction during development
- Supports lots of clients without config churn
Cons
- Very easy to make unsafe
- Often hides authorization design problems
- Dangerous with cookies, sessions, or internal APIs
My opinion: if your CORS policy is “whatever the client asks for,” you probably don’t have a policy.
Preflight behavior: where apps usually break
Most CORS bugs are preflight bugs.
A browser sends an OPTIONS request before the real request when the request is “non-simple,” like:
AuthorizationheaderContent-Type: application/json- methods such as
PUT,PATCH,DELETE
Your server has to answer that preflight correctly.
In Actix-web, actix-cors handles this well when configured properly. The common mistakes are:
- forgetting to allow
Authorization - forgetting
Content-Type - allowing
GETandPOSTbut notOPTIONS - putting CORS middleware in the wrong place
- returning app errors before CORS headers are applied
A decent API config often looks like this:
use actix_cors::Cors;
use actix_web::{http, App, HttpServer};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().wrap(
Cors::default()
.allowed_origin("https://app.example.com")
.allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE"])
.allowed_headers(vec![
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::header::ACCEPT,
])
.expose_headers(vec!["ETag", "Link", "X-Request-Id"])
.max_age(86400)
)
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
max_age can reduce preflight chatter. Nice for performance, though I wouldn’t treat it as a magic fix.
Exposed headers: underrated part of API design
A lot of developers configure allow-origin and stop thinking.
Then the frontend wants to read:
ETag- pagination via
Link - rate-limit headers
- request tracing IDs
The browser blocks access unless those headers are exposed.
GitHub’s API does this well by exposing a long list of useful headers, including ETag, Link, and rate-limit metadata. That’s a practical model for browser-consumed APIs.
In Actix-web:
.expose_headers(vec![
"ETag",
"Link",
"Location",
"Retry-After",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
"X-Request-Id",
])
Be intentional here. Expose what the client needs, not every internal header you happen to send.
Security boundaries CORS does not enforce
CORS is not an authentication mechanism. CORS is not CSRF protection. CORS is not a substitute for server-side authorization.
This is where teams get confused.
CORS only controls whether browser JavaScript can read a cross-origin response. It does not stop the request from being sent in many cases, and it does not protect non-browser clients at all.
If you’re using cookies, you also need proper CSRF defenses. If you’re hardening response behavior, look at other headers too. If you’re branching into broader browser security headers, https://csp-guide.com is the relevant companion topic for CSP and related policies.
My recommendation
For most Actix-web apps:
- use a strict allowlist
- enable credentials only when you actually need them
- explicitly allow headers and methods
- expose only the response headers your frontend needs
- avoid origin reflection unless you have a very controlled validation layer
Use wildcard CORS only for truly public, stateless APIs.
That’s the boring answer, which is usually the correct answer in security. Rust helps you write reliable services. CORS still demands clear policy decisions, and Actix-web won’t save you from a bad one.
For framework details and current API behavior, check the official Actix documentation and crate docs: