Caddy makes the easy path easy, but CORS is still CORS. The browser enforces it, the server has to answer correctly, and one wrong header can turn a simple API call into a weird frontend bug that eats an afternoon.

This guide is the version I wish I had the first few times I configured CORS behind a reverse proxy.

What CORS is actually doing

CORS is the browser asking:

  • Can https://app.example.com read responses from https://api.example.com?
  • Can it send credentials like cookies or Authorization?
  • Can it use non-simple methods like PUT or custom headers like X-API-Key?

The server answers with headers such as:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Credentials
  • Access-Control-Expose-Headers

For some requests, the browser sends a preflight OPTIONS request first.

Caddy doesn’t have “CORS mode” built in as a single switch. You usually implement it with header, @matchers, handle, and sometimes reverse_proxy.

The safest mental model

Use these rules:

  1. If your API is public and does not use cookies or auth tied to browser credentials, Access-Control-Allow-Origin: * is often fine.
  2. If you need cookies or Authorization in browser requests, do not use * for Access-Control-Allow-Origin.
  3. If you allow credentials, return the exact allowed origin.
  4. Handle preflight explicitly.
  5. Add Vary: Origin when the response changes based on the request’s Origin.

That last one gets skipped a lot and causes cache bugs.

Minimal public API CORS in Caddy

If you want any site to read your API and you do not allow credentials:

api.example.com {
	reverse_proxy localhost:8080

	header {
		Access-Control-Allow-Origin *
		Access-Control-Expose-Headers "ETag, Link, Location, Retry-After"
	}
}
```text

That’s enough for basic `GET` requests from browsers when no preflight is involved.

The `Access-Control-Expose-Headers` part matters if your frontend needs to read response headers from JavaScript. A nice real-world reference is GitHub’s API, which exposes headers like this:

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 access-control-allow-origin: *


That’s a good pattern for public APIs: broad origin access, but explicit exposed headers.

## Public API with preflight support

Once the browser sends `OPTIONS`, you need to answer it cleanly.

```caddyfile
api.example.com {
	@preflight {
		method OPTIONS
		header Origin *
		header Access-Control-Request-Method *
	}

	handle @preflight {
		header {
			Access-Control-Allow-Origin *
			Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
			Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
			Access-Control-Max-Age "86400"
		}
		respond "" 204
	}

	reverse_proxy localhost:8080

	header {
		Access-Control-Allow-Origin *
		Access-Control-Expose-Headers "ETag, Link, Location, Retry-After"
	}
}

A few opinions here:

  • 204 is nicer than 200 for preflight. No body, no ambiguity.
  • Access-Control-Max-Age reduces preflight spam.
  • Don’t blindly allow every header unless you actually want to.

Credentialed CORS with an allowlist

This is the setup people usually need for SPAs talking to an API with cookies or bearer tokens.

You must return the exact origin, not *.

api.example.com {
	@allowed_origin header Origin https://app.example.com

	@preflight {
		method OPTIONS
		header Origin https://app.example.com
		header Access-Control-Request-Method *
	}

	handle @preflight {
		header {
			Access-Control-Allow-Origin "https://app.example.com"
			Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
			Access-Control-Allow-Headers "Content-Type, Authorization"
			Access-Control-Allow-Credentials true
			Access-Control-Max-Age "86400"
			Vary Origin
		}
		respond "" 204
	}

	reverse_proxy localhost:8080

	header @allowed_origin {
		Access-Control-Allow-Origin "https://app.example.com"
		Access-Control-Allow-Credentials true
		Vary Origin
	}
}
```text

If your frontend uses `fetch(..., { credentials: "include" })`, this is the kind of config you need.

## Multiple allowed origins

Caddyfile doesn’t have the world’s fanciest dynamic logic, but you can still do this cleanly with named matchers.

```caddyfile
api.example.com {
	@origin_app header Origin https://app.example.com
	@origin_admin header Origin https://admin.example.com

	@preflight_app {
		method OPTIONS
		header Origin https://app.example.com
		header Access-Control-Request-Method *
	}

	@preflight_admin {
		method OPTIONS
		header Origin https://admin.example.com
		header Access-Control-Request-Method *
	}

	handle @preflight_app {
		header {
			Access-Control-Allow-Origin "https://app.example.com"
			Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
			Access-Control-Allow-Headers "Content-Type, Authorization"
			Access-Control-Allow-Credentials true
			Access-Control-Max-Age "86400"
			Vary Origin
		}
		respond "" 204
	}

	handle @preflight_admin {
		header {
			Access-Control-Allow-Origin "https://admin.example.com"
			Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
			Access-Control-Allow-Headers "Content-Type, Authorization"
			Access-Control-Allow-Credentials true
			Access-Control-Max-Age "86400"
			Vary Origin
		}
		respond "" 204
	}

	reverse_proxy localhost:8080

	header @origin_app {
		Access-Control-Allow-Origin "https://app.example.com"
		Access-Control-Allow-Credentials true
		Vary Origin
	}

	header @origin_admin {
		Access-Control-Allow-Origin "https://admin.example.com"
		Access-Control-Allow-Credentials true
		Vary Origin
	}
}

A little repetitive, yes. Also very readable. I’ll take readable over clever for security config every time.

Reflecting the Origin header: usually a bad shortcut

People often want this behavior:

  • If the request Origin is in my allowlist, echo it back.
  • Otherwise, send nothing.

That’s valid. But if you implement it sloppily and reflect any origin, you’ve basically disabled origin protection.

If you really need dynamic behavior, prefer your app to do it, where allowlist logic is easier to maintain and test. Let Caddy handle static cases well.

CORS only on /api/*

Don’t spray CORS headers on your whole site unless you mean to.

example.com {
	@api path /api/*

	handle @api {
		@preflight {
			method OPTIONS
			header Origin *
			header Access-Control-Request-Method *
		}

		handle @preflight {
			header {
				Access-Control-Allow-Origin *
				Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
				Access-Control-Allow-Headers "Content-Type, Authorization"
				Access-Control-Max-Age "86400"
			}
			respond "" 204
		}

		reverse_proxy localhost:8080

		header {
			Access-Control-Allow-Origin *
			Access-Control-Expose-Headers "ETag, Link"
		}
	}

	handle {
		root * /var/www/html
		file_server
	}
}
```text

That keeps static pages and assets separate from API behavior.

## Common mistakes

### 1. Using `*` with credentials

This is invalid:

Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true


Browsers reject it.

### 2. Forgetting preflight

Your API works in `curl`, but the browser fails because it sends:

OPTIONS /endpoint Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: authorization,content-type


If Caddy or the upstream app doesn’t answer that correctly, the real request never happens.

### 3. Forgetting `Vary: Origin`

If you return different `Access-Control-Allow-Origin` values for different callers and omit:

Vary: Origin


shared caches can serve the wrong CORS response.

### 4. Allowing too many headers and methods

This works:

Access-Control-Allow-Methods: * Access-Control-Allow-Headers: *


Well, sometimes, depending on browser behavior and context. I avoid it. Be explicit.

### 5. Thinking CORS protects your API from non-browser clients

It doesn’t. `curl`, backend jobs, mobile apps, and attackers can ignore CORS completely. CORS is a browser read-sharing policy, not an authentication system.

## Debugging with curl

Simple request:

curl -i https://api.example.com/data
-H ‘Origin: https://app.example.com


Preflight request:

curl -i -X OPTIONS https://api.example.com/data
-H ‘Origin: https://app.example.com
-H ‘Access-Control-Request-Method: POST’
-H ‘Access-Control-Request-Headers: content-type, authorization’


You want to see the matching CORS headers in the response.

## When to set CORS in Caddy vs the app

My rule:

- Set it in **Caddy** when the policy is simple and edge-wide.
- Set it in the **app** when policy depends on routes, tenants, databases, or dynamic allowlists.

If your API already emits CORS headers, don’t also bolt on conflicting ones in Caddy unless you enjoy debugging duplicate header behavior.

## A solid default for many public APIs

This is a practical baseline:

```caddyfile
api.example.com {
	@preflight {
		method OPTIONS
		header Origin *
		header Access-Control-Request-Method *
	}

	handle @preflight {
		header {
			Access-Control-Allow-Origin *
			Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
			Access-Control-Allow-Headers "Content-Type, Authorization"
			Access-Control-Max-Age "86400"
		}
		respond "" 204
	}

	reverse_proxy localhost:8080

	header {
		Access-Control-Allow-Origin *
		Access-Control-Expose-Headers "ETag, Link, Location, Retry-After"
	}
}

And for credentialed browser apps, switch to explicit origins and add:

Access-Control-Allow-Credentials: true
Vary: Origin

That’s the whole game.

For Caddy syntax details, check the official docs: https://caddyserver.com/docs/