CORS in Go, Rust, and Python
You've made it past the JavaScript chapter. Congratulations. Now we get to talk about the languages where people tend to think they don't have CORS problems — right up until the moment their frontend colleague walks over with That Look on their face.
The fundamental challenge is identical in every language: your server needs to
return the right headers on the right requests, including responding to OPTIONS
preflight requests that your application routes probably don't handle. The specifics
of how you bolt that onto your HTTP stack vary quite a bit, though.
Let's work through Go, Rust, and Python — covering the dominant web frameworks in each, from the quick-and-dirty setup to a hardened production configuration.
Go
Go's standard library gives you a perfectly capable HTTP server with zero CORS awareness. This is both a blessing (you can do exactly what you want) and a curse (you will do it wrong the first time).
Manual CORS in net/http
Let's start with the raw approach, because understanding it makes every library make more sense.
package main
import (
"fmt"
"net/http"
)
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Check if the origin is allowed
allowedOrigins := map[string]bool{
"https://app.example.com": true,
"https://staging.example.com": true,
}
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
// Handle preflight
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message": "hello from Go"}`)
})
http.ListenAndServe(":8080", corsMiddleware(mux))
}
This works. It's also the kind of thing that accumulates subtle bugs over six months of feature work. Notice the things you have to remember:
- Always set
Vary: Originwhen the response varies by origin (it does). - Return early on OPTIONS — if you let it fall through to your handler, you'll probably get a 405 Method Not Allowed.
- Check the origin against an allowlist — don't just reflect it back blindly (we'll get to why in the security chapter).
The rs/cors Package
The most popular standalone CORS library for Go is rs/cors. It handles the fiddly bits properly.
Minimal configuration:
package main
import (
"fmt"
"net/http"
"github.com/rs/cors"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message": "hello"}`)
})
// Allow everything — fine for local development, terrible for production
handler := cors.AllowAll().Handler(mux)
http.ListenAndServe(":8080", handler)
}
Production configuration:
c := cors.New(cors.Options{
AllowedOrigins: []string{"https://app.example.com", "https://staging.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Request-ID"},
ExposedHeaders: []string{"X-Request-ID", "X-RateLimit-Remaining"},
AllowCredentials: true,
MaxAge: 86400, // 24 hours, in seconds
Debug: false, // Set true during development — logs every CORS decision
})
handler := c.Handler(mux)
The Debug: true option is genuinely useful. It logs exactly why a request was
allowed or rejected, which is worth its weight in gold when you're staring at
a No 'Access-Control-Allow-Origin' header error in DevTools.
Common gotcha: AllowedOrigins: []string{"*"} and AllowCredentials: true
will not work. The library will refuse, and it's right to do so — the spec forbids
this combination. You must list explicit origins when credentials are involved.
Chi Middleware
If you're using the Chi router, CORS comes as a first-party middleware:
package main
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
)
func main() {
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Get("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"message": "hello from Chi"}`)
})
http.ListenAndServe(":8080", r)
}
The Chi CORS middleware is actually a thin wrapper around rs/cors, so the behavior
is identical. Use whichever feels right.
Testing Go CORS with curl
A simple request:
curl -v -H "Origin: https://app.example.com" \
http://localhost:8080/api/data
You should see in the response:
< Access-Control-Allow-Origin: https://app.example.com
< Vary: Origin
A preflight request:
curl -v -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
http://localhost:8080/api/data
Expected response:
< HTTP/1.1 204 No Content
< Access-Control-Allow-Origin: https://app.example.com
< Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
< Access-Control-Allow-Headers: Content-Type, Authorization
< Access-Control-Max-Age: 86400
< Vary: Origin
If you get a 200 OK with an HTML body instead of 204 No Content, your CORS
middleware isn't intercepting the preflight — the request is falling through to
your default handler. Check your middleware ordering.
Rust
Rust's web ecosystem has matured significantly. The two frameworks you're most likely
to encounter are Axum (built on tower and hyper) and Actix-web. Both
have solid CORS support, but they wire it up differently.
Axum with tower-http CorsLayer
Axum doesn't bundle CORS handling — it delegates to tower-http, which provides
a CorsLayer that works as Tower middleware.
Minimal configuration (development):
use axum::{routing::get, Json, Router}; use serde_json::{json, Value}; use tower_http::cors::CorsLayer; #[tokio::main] async fn main() { let app = Router::new() .route("/api/data", get(handler)) .layer(CorsLayer::permissive()); // Allows everything — development only! let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); axum::serve(listener, app).await.unwrap(); } async fn handler() -> Json<Value> { Json(json!({"message": "hello from Axum"})) }
CorsLayer::permissive() is the equivalent of Access-Control-Allow-Origin: *
with all methods and headers allowed. It's great for hacking on something locally
and a liability anywhere else.
Production configuration:
use axum::{routing::{get, post}, Json, Router}; use http::{header, HeaderValue, Method}; use serde_json::{json, Value}; use tower_http::cors::CorsLayer; use std::time::Duration; #[tokio::main] async fn main() { let cors = CorsLayer::new() .allow_origin([ "https://app.example.com".parse::<HeaderValue>().unwrap(), "https://staging.example.com".parse::<HeaderValue>().unwrap(), ]) .allow_methods([ Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, ]) .allow_headers([ header::CONTENT_TYPE, header::AUTHORIZATION, HeaderName::from_static("x-request-id"), ]) .expose_headers([ HeaderName::from_static("x-request-id"), HeaderName::from_static("x-ratelimit-remaining"), ]) .allow_credentials(true) .max_age(Duration::from_secs(86400)); let app = Router::new() .route("/api/data", get(get_data)) .route("/api/data", post(create_data)) .layer(cors); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap(); axum::serve(listener, app).await.unwrap(); } async fn get_data() -> Json<Value> { Json(json!({"message": "hello from Axum"})) } async fn create_data(Json(body): Json<Value>) -> Json<Value> { Json(json!({"created": true, "data": body})) }
A few Rust-specific notes:
- The
.parse::<HeaderValue>().unwrap()dance is unavoidable. Axum uses typed headers from thehttpcrate, so origins must be validHeaderValues. If you're loading origins from config, handle the parse error properly instead of unwrapping. allow_credentials(true)will make the layer refuse to combine with a wildcard origin, just like every other well-behaved CORS implementation.- Layer ordering matters. In Axum, layers applied later in the chain execute first. If you have auth middleware that rejects requests before CORS headers are set, preflight requests will fail. Put the CORS layer after (i.e., outermost) your auth layer.
You'll also need these in your Cargo.toml:
[dependencies]
axum = "0.8"
http = "1"
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors"] }
Common gotcha with Axum: Forgetting to enable the cors feature on tower-http.
The compiler error you'll get is about CorsLayer not existing, not about a missing
feature flag. Every Rust developer has lost ten minutes to this at least once.
Actix-web CORS Middleware
Actix-web has its own CORS crate: actix-cors.
use actix_cors::Cors; use actix_web::{get, web, App, HttpResponse, HttpServer}; #[get("/api/data")] async fn get_data() -> HttpResponse { HttpResponse::Ok().json(serde_json::json!({"message": "hello from Actix"})) } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { // Production CORS config let cors = Cors::default() .allowed_origin("https://app.example.com") .allowed_origin("https://staging.example.com") .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"]) .allowed_headers(vec![ actix_web::http::header::CONTENT_TYPE, actix_web::http::header::AUTHORIZATION, ]) .expose_headers(vec!["X-Request-ID"]) .supports_credentials() .max_age(86400); App::new() .wrap(cors) .service(get_data) }) .bind("0.0.0.0:8080")? .run() .await }
Common gotcha with Actix: The CORS middleware is created inside the closure
passed to HttpServer::new. This closure runs once per worker thread. If you're
loading allowed origins from a database or external config, make sure that lookup
happens before the closure, and you clone/share the result into each worker.
For a permissive development config in Actix:
#![allow(unused)] fn main() { let cors = Cors::permissive(); }
Testing Rust CORS with curl
Same curl commands work regardless of backend language — CORS is a protocol, not a language feature:
# Simple GET with Origin header
curl -v -H "Origin: https://app.example.com" \
http://localhost:8080/api/data
# Preflight for a POST with JSON body
curl -v -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
http://localhost:8080/api/data
If you want to verify that a disallowed origin is properly rejected:
curl -v -H "Origin: https://evil.example.com" \
http://localhost:8080/api/data
You should see no Access-Control-Allow-Origin header in the response. The
request still succeeds (the server doesn't block it — the browser does), but
without the CORS header, a browser would refuse to let JavaScript read the response.
Python
Python has three major web frameworks these days, each with its own CORS story.
FastAPI with CORSMiddleware
FastAPI has CORS support built into Starlette, so there's no extra package to install. This is the smoothest CORS experience in the Python ecosystem.
Minimal configuration:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Allow everything — development only
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/data")
async def get_data():
return {"message": "hello from FastAPI"}
Production configuration:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = [
"https://app.example.com",
"https://staging.example.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
expose_headers=["X-Request-ID", "X-RateLimit-Remaining"],
max_age=86400,
)
@app.get("/api/data")
async def get_data():
return {"message": "hello from FastAPI"}
@app.post("/api/data")
async def create_data(data: dict):
return {"created": True, "data": data}
Run it with:
uvicorn main:app --host 0.0.0.0 --port 8080
Common gotcha: If you set allow_origins=["*"] and allow_credentials=True,
Starlette will silently allow it — unlike Go's rs/cors, it won't warn you. Your
browser will reject the response, though, and you'll spend twenty minutes wondering
why cookies aren't being sent. The spec says wildcard origin + credentials is
invalid. Starlette trusts you to know that. Maybe it shouldn't.
Full FastAPI example with error handling:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
import os
app = FastAPI(title="CORS Example API")
# Load origins from environment
allowed_origins = os.getenv(
"ALLOWED_ORIGINS",
"http://localhost:3000,http://localhost:5173"
).split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
expose_headers=["X-Request-ID"],
max_age=600, # 10 minutes — reasonable for most APIs
)
class Item(BaseModel):
name: str
description: Optional[str] = None
items_db = {}
@app.get("/api/items")
async def list_items():
return {"items": list(items_db.values())}
@app.post("/api/items")
async def create_item(item: Item):
item_id = len(items_db) + 1
items_db[item_id] = {"id": item_id, **item.model_dump()}
return items_db[item_id]
@app.get("/api/items/{item_id}")
async def get_item(item_id: int):
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
return items_db[item_id]
Note that CORS headers are added even on error responses (404, 500, etc.) because the middleware wraps the entire application. This is the correct behavior — your frontend needs to be able to read error responses too. Not all frameworks get this right by default.
Flask with Flask-CORS
pip install flask flask-cors
Minimal configuration:
from flask import Flask, jsonify
from flask_cors import CORS
app = Flask(__name__)
CORS(app) # Allows all origins — development only
@app.route("/api/data")
def get_data():
return jsonify({"message": "hello from Flask"})
Production configuration:
from flask import Flask, jsonify, request
from flask_cors import CORS
app = Flask(__name__)
CORS(app,
origins=["https://app.example.com", "https://staging.example.com"],
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
expose_headers=["X-Request-ID", "X-RateLimit-Remaining"],
supports_credentials=True,
max_age=86400,
)
@app.route("/api/data", methods=["GET"])
def get_data():
return jsonify({"message": "hello from Flask"})
@app.route("/api/data", methods=["POST"])
def create_data():
data = request.get_json()
return jsonify({"created": True, "data": data}), 201
Per-route CORS — Flask-CORS also supports decorating individual routes:
from flask_cors import cross_origin
@app.route("/api/public")
@cross_origin() # Wide open
def public_data():
return jsonify({"public": True})
@app.route("/api/private")
@cross_origin(origins=["https://app.example.com"], supports_credentials=True)
def private_data():
return jsonify({"private": True})
Common gotcha: Flask-CORS's CORS(app) with no arguments allows * for
origins and doesn't set supports_credentials. If you later add
supports_credentials=True without specifying origins, you're back to the
wildcard-plus-credentials problem. Flask-CORS handles this better than Starlette —
it will reflect the requesting origin back instead of sending * when credentials
are enabled. But you should still explicitly list your origins.
Django with django-cors-headers
pip install django-cors-headers
In settings.py:
INSTALLED_APPS = [
# ... your apps ...
'corsheaders',
# ... other apps ...
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # MUST be before CommonMiddleware
'django.middleware.common.CommonMiddleware',
# ... other middleware ...
]
Development configuration:
CORS_ALLOW_ALL_ORIGINS = True # Don't even think about it in production
Production configuration:
CORS_ALLOWED_ORIGINS = [
"https://app.example.com",
"https://staging.example.com",
]
# Or use a regex for subdomains:
CORS_ALLOWED_ORIGIN_REGEXES = [
r"^https://\w+\.example\.com$",
]
CORS_ALLOW_METHODS = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
]
CORS_ALLOW_HEADERS = [
"accept",
"authorization",
"content-type",
"x-request-id",
]
CORS_EXPOSE_HEADERS = [
"x-request-id",
"x-ratelimit-remaining",
]
CORS_ALLOW_CREDENTIALS = True
CORS_PREFLIGHT_MAX_AGE = 86400
Critical gotcha with Django: The CorsMiddleware must be placed as high as
possible in the MIDDLEWARE list — specifically before CommonMiddleware and
before any middleware that might generate responses (like SecurityMiddleware
for HTTPS redirects). If a middleware higher in the chain returns a response before
CorsMiddleware runs, the CORS headers won't be added.
I've seen this exact issue in production: Django's SecurityMiddleware was
redirecting HTTP to HTTPS, and because it was above CorsMiddleware in the
middleware list, the redirect response had no CORS headers. The browser saw a
redirect without CORS headers and blocked the request. The fix was a one-line
middleware reorder. The debugging took three hours.
Another Django gotcha: If you're using CORS_ALLOWED_ORIGIN_REGEXES, test your
regex carefully. A common mistake:
# WRONG: matches evil-example.com, notexample.com, etc.
CORS_ALLOWED_ORIGIN_REGEXES = [
r"^https://.*example\.com$",
]
# RIGHT: anchored to subdomain pattern
CORS_ALLOWED_ORIGIN_REGEXES = [
r"^https://[a-z0-9]+\.example\.com$",
]
Testing Python CORS with curl
# Test simple request
curl -v -H "Origin: https://app.example.com" \
http://localhost:8080/api/data
# Expected response headers:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Credentials: true
# Vary: Origin
# Test preflight
curl -v -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
http://localhost:8080/api/data
# Expected response:
# HTTP/1.1 200 OK (or 204 No Content, depending on framework)
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type, Authorization
# Access-Control-Max-Age: 86400
# Access-Control-Allow-Credentials: true
# Test disallowed origin
curl -v -H "Origin: https://evil.example.com" \
http://localhost:8080/api/data
# Expected: no Access-Control-Allow-Origin header in response
Cross-Language Comparison
Here's the cheat sheet for when you inevitably switch between projects:
| Feature | Go (rs/cors) | Rust (Axum tower-http) | Python (FastAPI) |
|---|---|---|---|
| Permissive dev mode | cors.AllowAll() | CorsLayer::permissive() | allow_origins=["*"] |
| Wildcard + creds guard | Errors at runtime | Errors at compile time | Silently broken |
Vary: Origin | Automatic | Automatic | Automatic |
| Preflight handling | Automatic | Automatic | Automatic |
| Debug logging | Debug: true | Use tracing crate | Starlette logging |
| CORS on error responses | Depends on error handling | Depends on error handling | Automatic (middleware) |
The "CORS on Error Responses" Problem
This deserves special attention because it bites every language equally. If your application panics, returns a 500, or hits an error path that bypasses the normal middleware chain, the CORS headers may be missing from the error response.
In Go, if you use http.Error() directly inside a handler without going through
your CORS-aware response writer, you lose the headers.
In Rust/Axum, if a layer inside the CORS layer returns an error response (like auth middleware returning 401), the CORS layer still wraps it correctly. But if something panics and the panic handler generates a response, you may lose CORS headers.
In Python/FastAPI, the middleware wraps everything including exceptions, so CORS headers appear even on 500 responses. This is the most developer-friendly behavior.
The browser DevTools symptom: Your API returns a 500, you see the error in the Network tab, but the Console shows a CORS error instead of the actual error details. The 500 response arrived without CORS headers, so the browser won't let your JavaScript read it. You know there's a server error, but you can't see what error. Wonderful.
The fix in every language is the same: make sure your CORS middleware/layer is the outermost layer in your stack, wrapping everything else including error handlers and panic recovery.
Summary
Regardless of your language choice, the CORS configuration checklist is the same:
- List your allowed origins explicitly — no wildcards in production with credentials.
- Handle OPTIONS preflight requests — use middleware, don't do it manually unless you enjoy pain.
- Set
Vary: Origin— every CORS library does this automatically, but verify it if you're rolling your own. - Ensure CORS headers appear on error responses — test with
curlagainst an endpoint that returns a 500. - Set a reasonable
Max-Age— 600 seconds (10 minutes) is safe; 86400 (24 hours) is aggressive but fine for stable APIs. - Expose headers your frontend needs —
Content-Typeand status are always available, everything else requiresAccess-Control-Expose-Headers.
Now go fix your colleague's CORS error. You know you want to.