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:

  1. Always set Vary: Origin when the response varies by origin (it does).
  2. Return early on OPTIONS — if you let it fall through to your handler, you'll probably get a 405 Method Not Allowed.
  3. 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 the http crate, so origins must be valid HeaderValues. 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:

FeatureGo (rs/cors)Rust (Axum tower-http)Python (FastAPI)
Permissive dev modecors.AllowAll()CorsLayer::permissive()allow_origins=["*"]
Wildcard + creds guardErrors at runtimeErrors at compile timeSilently broken
Vary: OriginAutomaticAutomaticAutomatic
Preflight handlingAutomaticAutomaticAutomatic
Debug loggingDebug: trueUse tracing crateStarlette logging
CORS on error responsesDepends on error handlingDepends on error handlingAutomatic (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:

  1. List your allowed origins explicitly — no wildcards in production with credentials.
  2. Handle OPTIONS preflight requests — use middleware, don't do it manually unless you enjoy pain.
  3. Set Vary: Origin — every CORS library does this automatically, but verify it if you're rolling your own.
  4. Ensure CORS headers appear on error responses — test with curl against an endpoint that returns a 500.
  5. Set a reasonable Max-Age — 600 seconds (10 minutes) is safe; 86400 (24 hours) is aggressive but fine for stable APIs.
  6. Expose headers your frontend needsContent-Type and status are always available, everything else requires Access-Control-Expose-Headers.

Now go fix your colleague's CORS error. You know you want to.