|
| 1 | +# CORS Configuration |
| 2 | + |
| 3 | +How to configure Cross-Origin Resource Sharing (CORS) for Nerva APIs -- from the permissive defaults that make local development easy to the explicit allowlists production requires, plus how to debug the errors browsers report along the way. |
| 4 | + |
| 5 | +## What CORS Is |
| 6 | + |
| 7 | +Browsers enforce the same-origin policy: JavaScript on a page may only read responses from the page's own origin (scheme + host + port). CORS is the protocol a server uses to tell browsers which *other* origins may read its responses. When your frontend at `https://app.example.com` calls your API at `https://api.example.com`, the API must opt in by returning `Access-Control-Allow-Origin` headers, or the browser blocks the frontend from reading the response. |
| 8 | + |
| 9 | +Two facts prevent most CORS confusion: |
| 10 | + |
| 11 | +- **CORS is enforced by browsers, not servers.** `curl`, Postman, and server-to-server calls ignore it entirely. If a request works in Postman but fails in the browser, that points to CORS. |
| 12 | +- **CORS is not authentication.** It controls which websites may read responses through a user's browser. Your API still needs auth middleware -- a permissive CORS policy does not "open" your API, and a strict one does not protect it. |
| 13 | + |
| 14 | +For requests a plain HTML form could not produce -- the `Authorization` or `X-API-Key` header, a JSON body, methods like `PUT`, `PATCH`, or `DELETE` -- the browser first sends an `OPTIONS` "preflight" request asking permission. Simple `GET` requests skip preflight, which is why an API can appear to work for reads and fail only on writes. |
| 15 | + |
| 16 | +## How Hono's `cors()` Middleware Works |
| 17 | + |
| 18 | +Import the middleware from `hono/cors`. It behaves identically on Node.js and Cloudflare Workers: |
| 19 | + |
| 20 | +```typescript |
| 21 | +import { Hono } from "hono"; |
| 22 | +import { cors } from "hono/cors"; |
| 23 | + |
| 24 | +const app = new Hono(); |
| 25 | + |
| 26 | +app.use("*", cors()); |
| 27 | +``` |
| 28 | + |
| 29 | +The middleware does two things: |
| 30 | + |
| 31 | +1. **Adds `Access-Control-*` headers** to every response on the paths it covers. |
| 32 | +2. **Answers preflights itself.** `OPTIONS` requests get a `204 No Content` response with the allow headers, and never reach your route handlers. |
| 33 | + |
| 34 | +### Options Reference |
| 35 | + |
| 36 | +| Option | Type | Default | Response header | |
| 37 | +|--------|------|---------|-----------------| |
| 38 | +| `origin` | `string \| string[] \| function` | `"*"` | `Access-Control-Allow-Origin` | |
| 39 | +| `allowMethods` | `string[]` | `["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"]` | `Access-Control-Allow-Methods` | |
| 40 | +| `allowHeaders` | `string[]` | `[]` (mirrors the headers the preflight asks for) | `Access-Control-Allow-Headers` | |
| 41 | +| `exposeHeaders` | `string[]` | `[]` | `Access-Control-Expose-Headers` | |
| 42 | +| `credentials` | `boolean` | `false` | `Access-Control-Allow-Credentials` | |
| 43 | +| `maxAge` | `number` (seconds) | unset | `Access-Control-Max-Age` | |
| 44 | + |
| 45 | +When `origin` is an array or a function, the middleware echoes the matching origin back in `Access-Control-Allow-Origin` and adds `Vary: Origin` so caches store per-origin responses correctly. When the origin does not match, the header is omitted and the browser blocks the response. |
| 46 | + |
| 47 | +`exposeHeaders` controls which response headers client JavaScript may read beyond the safelisted basics. Expose rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`) so frontends can back off before hitting 429s. |
| 48 | + |
| 49 | +### Middleware Order |
| 50 | + |
| 51 | +Register `cors()` before auth and rate limiting. Preflights never carry an `Authorization` header, so if auth middleware runs first, every preflight fails with a 401 and the browser reports it as a CORS error: |
| 52 | + |
| 53 | +```typescript |
| 54 | +app.use("*", cors({ origin: allowedOrigins })); // 1. CORS first |
| 55 | +app.use("*", rateLimiter({ max: 100, window: "1m" })); // 2. Rate limiting |
| 56 | +app.use("/api/v1/*", authMiddleware); // 3. Auth after CORS |
| 57 | +``` |
| 58 | + |
| 59 | +Also register it on a path at least as broad as your routes. A `cors()` mounted on `/api/*` does nothing for a route mounted at `/auth/login`. |
| 60 | + |
| 61 | +### Defaults in Nerva Projects |
| 62 | + |
| 63 | +The starter templates (`templates/snippets/`) ship with a bare `app.use("*", cors())` -- wildcard origin, suitable for development. The schema-to-api pipeline generates CORS middleware in Phase 4 from the `security.cors` block of `.claude/pipeline.config.json`: |
| 64 | + |
| 65 | +```json |
| 66 | +"cors": { |
| 67 | + "enabled": true, |
| 68 | + "origins": ["*"], |
| 69 | + "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], |
| 70 | + "allowHeaders": ["Content-Type", "Authorization", "X-API-Key"], |
| 71 | + "maxAge": 86400 |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +`origins: ["*"]` is a development default. Edit this block before running `/build-from-schema` to bake your real origins into the generated middleware, or override the origins at runtime with an environment variable as shown below. |
| 76 | + |
| 77 | +## Development Configuration |
| 78 | + |
| 79 | +For local development the wildcard default is fine -- any localhost port, tool, or teammate's machine can call the API without ceremony: |
| 80 | + |
| 81 | +```typescript |
| 82 | +import { cors } from "hono/cors"; |
| 83 | + |
| 84 | +// Defaults to Access-Control-Allow-Origin: * |
| 85 | +app.use("*", cors()); |
| 86 | +``` |
| 87 | + |
| 88 | +The one thing a wildcard cannot do is credentials. If you are testing cookie-based auth locally, browsers reject `*` combined with credentials, so list your dev origins explicitly: |
| 89 | + |
| 90 | +```typescript |
| 91 | +app.use( |
| 92 | + "*", |
| 93 | + cors({ |
| 94 | + origin: ["http://localhost:3000", "http://localhost:5173", "http://localhost:8787"], |
| 95 | + credentials: true, |
| 96 | + }) |
| 97 | +); |
| 98 | +``` |
| 99 | + |
| 100 | +Ports are part of the origin: a frontend on `http://localhost:5173` is a different origin from `http://localhost:3000`, so list each one you use. |
| 101 | + |
| 102 | +## Production Configuration |
| 103 | + |
| 104 | +Never deploy with `origin: "*"`. A wildcard lets any website on the internet read your API's responses through its visitors' browsers. Production gets an explicit allowlist, driven by an environment variable so the same build runs in staging and production: |
| 105 | + |
| 106 | +```typescript |
| 107 | +// api/src/middleware/cors-config.ts |
| 108 | +import { cors } from "hono/cors"; |
| 109 | + |
| 110 | +// ALLOWED_ORIGINS="https://app.example.com,https://admin.example.com" |
| 111 | +const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "") |
| 112 | + .split(",") |
| 113 | + .map((origin) => origin.trim()) |
| 114 | + .filter((origin) => origin.length > 0); |
| 115 | + |
| 116 | +export const corsConfig = cors({ |
| 117 | + origin: allowedOrigins, |
| 118 | + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], |
| 119 | + allowHeaders: ["Content-Type", "Authorization", "X-API-Key"], |
| 120 | + exposeHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining", "X-Request-Id"], |
| 121 | + maxAge: 86400, |
| 122 | +}); |
| 123 | +``` |
| 124 | + |
| 125 | +Origins in the allowlist must match what the browser sends in the `Origin` header *exactly*: scheme, host, and port, with no trailing slash and no path. `https://app.example.com/` (trailing slash), `http://app.example.com` (wrong scheme), and `https://www.app.example.com` (different host) all fail to match `https://app.example.com`. |
| 126 | + |
| 127 | +When the allowlist is not a fixed set -- per-customer subdomains, preview deployments -- pass a function instead. Return the origin to allow it, or `null` to reject: |
| 128 | + |
| 129 | +```typescript |
| 130 | +export const corsConfig = cors({ |
| 131 | + origin: (origin) => { |
| 132 | + if (allowedOrigins.includes(origin)) return origin; |
| 133 | + if (origin.endsWith(".example.com") && origin.startsWith("https://")) return origin; |
| 134 | + return null; // No header set -- browser blocks the response |
| 135 | + }, |
| 136 | + allowHeaders: ["Content-Type", "Authorization", "X-API-Key"], |
| 137 | + maxAge: 86400, |
| 138 | +}); |
| 139 | +``` |
| 140 | + |
| 141 | +Keep suffix checks anchored with a leading dot (`.example.com`, not `example.com`) -- otherwise `https://evilexample.com` slips through. |
| 142 | + |
| 143 | +To switch configurations by environment on Node.js: |
| 144 | + |
| 145 | +```typescript |
| 146 | +const isProduction = process.env.NODE_ENV === "production"; |
| 147 | + |
| 148 | +app.use("*", isProduction ? corsConfig : cors()); |
| 149 | +``` |
| 150 | + |
| 151 | +On Cloudflare Workers, environment variables arrive per-request rather than at module scope -- see [Cloudflare Workers Considerations](#cloudflare-workers-considerations) for the equivalent pattern. |
| 152 | + |
| 153 | +## Credentials Mode |
| 154 | + |
| 155 | +By default, cross-origin requests carry no cookies. If your API uses cookie- or session-based auth, both sides must opt in. |
| 156 | + |
| 157 | +Server side, set `credentials: true`, which sends `Access-Control-Allow-Credentials: true`: |
| 158 | + |
| 159 | +```typescript |
| 160 | +app.use( |
| 161 | + "*", |
| 162 | + cors({ |
| 163 | + origin: ["https://app.example.com"], // Must be explicit -- browsers reject "*" with credentials |
| 164 | + credentials: true, |
| 165 | + }) |
| 166 | +); |
| 167 | +``` |
| 168 | + |
| 169 | +Client side, the frontend must ask for credentials explicitly: |
| 170 | + |
| 171 | +```typescript |
| 172 | +const res = await fetch("https://api.example.com/api/v1/todos", { |
| 173 | + credentials: "include", |
| 174 | +}); |
| 175 | +``` |
| 176 | + |
| 177 | +Rules that apply in credentials mode: |
| 178 | + |
| 179 | +- `Access-Control-Allow-Origin` must echo the exact origin. Browsers reject `*` when credentials are included, no exceptions. |
| 180 | +- Cross-site cookies need `SameSite=None; Secure` set on the cookie itself, which also means HTTPS everywhere. |
| 181 | +- Wildcards stop working elsewhere too: with credentials, browsers treat `*` in `Access-Control-Allow-Headers` as the literal header name, not a wildcard. List headers explicitly. |
| 182 | + |
| 183 | +If your API uses bearer tokens in the `Authorization` header (Nerva's default JWT strategy), leave `credentials` off. The token travels as a header, not a cookie, so credentials mode adds attack surface without adding anything you need. |
| 184 | + |
| 185 | +## Preflight Caching |
| 186 | + |
| 187 | +`maxAge` sets `Access-Control-Max-Age`, which tells the browser how long (in seconds) to cache a preflight result. While cached, repeat requests to the same endpoint skip the `OPTIONS` round-trip entirely -- on a chatty API this removes meaningful latency: |
| 188 | + |
| 189 | +```typescript |
| 190 | +app.use( |
| 191 | + "*", |
| 192 | + cors({ |
| 193 | + origin: allowedOrigins, |
| 194 | + maxAge: 86400, // Cache preflight results for 24 hours |
| 195 | + }) |
| 196 | +); |
| 197 | +``` |
| 198 | + |
| 199 | +What to know about the cache: |
| 200 | + |
| 201 | +- It is keyed per origin + URL. A long `maxAge` does not eliminate preflights across your API -- each endpoint preflights once. |
| 202 | +- Browsers cap the value: Chromium-based browsers honor at most 7200 (2 hours); Firefox honors up to 86400 (24 hours). Asking for more is harmless -- the browser clamps it. |
| 203 | +- Without the header, browsers cache preflights for only 5 seconds. |
| 204 | +- The pipeline default (`maxAge: 86400`) is right for stable production configs. |
| 205 | + |
| 206 | +The cache cuts both ways: while you are *changing* CORS configuration, cached preflight results keep serving the old policy. If a CORS fix seems to have no effect, tick "Disable cache" in the browser's Network tab before concluding the fix failed. |
| 207 | + |
| 208 | +## Common CORS Errors and How to Debug Them |
| 209 | + |
| 210 | +Before reading console errors, open the browser's **Network tab** and find the failing request and its preflight. The single most misleading thing about CORS errors: **any response without CORS headers reports as a CORS error, including 500s, crashes, and platform error pages.** A "CORS error" is often a server error wearing a disguise -- check the actual status code first. |
| 211 | + |
| 212 | +### "No 'Access-Control-Allow-Origin' header is present on the requested resource" |
| 213 | + |
| 214 | +The response had no CORS headers at all. Usual causes: |
| 215 | + |
| 216 | +- The `cors()` middleware is not registered, or is mounted on a narrower path than the route being called. |
| 217 | +- The origin was rejected by your allowlist (array/function form omits the header on no-match) -- compare the request's `Origin` header against the allowlist character by character. |
| 218 | +- The response never went through your middleware: the process crashed, or the platform returned its own error page (see the Workers section). |
| 219 | + |
| 220 | +### "The 'Access-Control-Allow-Origin' header has a value that is not equal to the supplied origin" |
| 221 | + |
| 222 | +The server echoed *an* origin, just not this one. Almost always an exact-match failure: `http` vs `https`, a missing or extra port, `www.` vs apex domain, or a trailing slash in the configured value. |
| 223 | + |
| 224 | +### "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'" |
| 225 | + |
| 226 | +The client sent `credentials: "include"` but the server is configured with the wildcard origin. Replace `*` with an explicit origin list and set `credentials: true` -- see [Credentials Mode](#credentials-mode). |
| 227 | + |
| 228 | +### "Request header field x-api-key is not allowed by Access-Control-Allow-Headers in preflight response" |
| 229 | + |
| 230 | +The preflight asked permission for a header your config does not list. Add it: |
| 231 | + |
| 232 | +```typescript |
| 233 | +cors({ allowHeaders: ["Content-Type", "Authorization", "X-API-Key"] }); |
| 234 | +``` |
| 235 | + |
| 236 | +Note this only bites when `allowHeaders` is set: a bare `cors()` mirrors whatever headers the preflight requests, so the error appears for the first time when you tighten configuration. |
| 237 | + |
| 238 | +### "Method PATCH is not allowed by Access-Control-Allow-Methods in preflight response" |
| 239 | + |
| 240 | +Same story for methods -- add the missing method to `allowMethods`. Hono's default set already includes `PATCH`; this usually means a custom `allowMethods` list left it out. |
| 241 | + |
| 242 | +### "Response to preflight request doesn't pass access control check: It does not have HTTP ok status" |
| 243 | + |
| 244 | +The `OPTIONS` request itself returned an error or a redirect: |
| 245 | + |
| 246 | +- **401/403** -- auth or rate-limiting middleware ran before `cors()`. Reorder so CORS is first. |
| 247 | +- **301/302** -- preflights must not redirect. Common culprits: HTTP-to-HTTPS redirects and trailing-slash rewrites. Point the frontend at the final URL. |
| 248 | +- **404** -- the `cors()` middleware path does not cover the requested path, so no preflight handler existed. |
| 249 | + |
| 250 | +### "The 'Access-Control-Allow-Origin' header contains multiple values" |
| 251 | + |
| 252 | +CORS headers are being added at two layers -- typically a reverse proxy or CDN rule *and* the Hono middleware, or `cors()` registered twice. Configure CORS in exactly one place. |
| 253 | + |
| 254 | +### Reproducing Preflights with curl |
| 255 | + |
| 256 | +The Network tab shows what happened; `curl` lets you test fixes without a frontend. Simulate the preflight: |
| 257 | + |
| 258 | +```bash |
| 259 | +curl -i -X OPTIONS https://api.example.com/api/v1/todos \ |
| 260 | + -H "Origin: https://app.example.com" \ |
| 261 | + -H "Access-Control-Request-Method: POST" \ |
| 262 | + -H "Access-Control-Request-Headers: content-type,authorization" |
| 263 | +``` |
| 264 | + |
| 265 | +A healthy preflight response looks like: |
| 266 | + |
| 267 | +``` |
| 268 | +HTTP/2 204 |
| 269 | +access-control-allow-origin: https://app.example.com |
| 270 | +access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS |
| 271 | +access-control-allow-headers: Content-Type,Authorization,X-API-Key |
| 272 | +access-control-max-age: 86400 |
| 273 | +vary: Origin |
| 274 | +``` |
| 275 | + |
| 276 | +Then test the actual request: |
| 277 | + |
| 278 | +```bash |
| 279 | +curl -i https://api.example.com/api/v1/todos -H "Origin: https://app.example.com" |
| 280 | +``` |
| 281 | + |
| 282 | +Remember `curl` does not enforce CORS -- it just shows you the headers. If `curl` shows the right headers but the browser still blocks, the browser is sending a different `Origin` than you tested (check the Network tab), or a cached preflight is still serving the old policy. |
| 283 | + |
| 284 | +## Cloudflare Workers Considerations |
| 285 | + |
| 286 | +Hono's `cors()` works unchanged on Workers, and nothing at the Cloudflare layer adds CORS headers for you. The platform differences worth knowing: |
| 287 | + |
| 288 | +### Environment Variables Arrive Per-Request |
| 289 | + |
| 290 | +Workers deliver configuration through bindings on `c.env`, not `process.env` at module scope. To drive origins from `wrangler.toml`, build the middleware inside a wrapper: |
| 291 | + |
| 292 | +```typescript |
| 293 | +import { Hono } from "hono"; |
| 294 | +import { cors } from "hono/cors"; |
| 295 | + |
| 296 | +type Bindings = { |
| 297 | + ALLOWED_ORIGINS: string; // Comma-separated, from wrangler.toml [vars] |
| 298 | +}; |
| 299 | + |
| 300 | +const app = new Hono<{ Bindings: Bindings }>(); |
| 301 | + |
| 302 | +app.use("*", (c, next) => { |
| 303 | + const corsMiddleware = cors({ |
| 304 | + origin: c.env.ALLOWED_ORIGINS.split(",").map((origin) => origin.trim()), |
| 305 | + allowHeaders: ["Content-Type", "Authorization", "X-API-Key"], |
| 306 | + maxAge: 86400, |
| 307 | + }); |
| 308 | + return corsMiddleware(c, next); |
| 309 | +}); |
| 310 | +``` |
| 311 | + |
| 312 | +Set the variable per environment in `wrangler.toml`: |
| 313 | + |
| 314 | +```toml |
| 315 | +[vars] |
| 316 | +ALLOWED_ORIGINS = "http://localhost:3000,http://localhost:5173" |
| 317 | + |
| 318 | +[env.staging.vars] |
| 319 | +ALLOWED_ORIGINS = "https://staging.app.example.com" |
| 320 | + |
| 321 | +[env.production.vars] |
| 322 | +ALLOWED_ORIGINS = "https://app.example.com" |
| 323 | +``` |
| 324 | + |
| 325 | +Deploy with `npx wrangler deploy --env production` and the production allowlist applies. |
| 326 | + |
| 327 | +### Uncaught Exceptions Look Like CORS Errors |
| 328 | + |
| 329 | +When a Worker throws an uncaught exception, Cloudflare returns its own error page -- which has no CORS headers, so the browser reports a CORS failure instead of the real error. If production suddenly "has CORS errors" on one endpoint, stream the logs before touching CORS config: |
| 330 | + |
| 331 | +```bash |
| 332 | +npx wrangler tail |
| 333 | +``` |
| 334 | + |
| 335 | +### Pages Preview Deployments |
| 336 | + |
| 337 | +Cloudflare Pages gives every preview deployment its own subdomain (`<hash>.my-app.pages.dev`), so a fixed allowlist cannot cover them. Use the function form with an anchored suffix check: |
| 338 | + |
| 339 | +```typescript |
| 340 | +origin: (origin) => { |
| 341 | + if (origin === "https://app.example.com") return origin; |
| 342 | + if (origin.endsWith(".my-app.pages.dev")) return origin; // Preview deployments |
| 343 | + return null; |
| 344 | +}, |
| 345 | +``` |
| 346 | + |
| 347 | +The leading dot matters: `.my-app.pages.dev` matches only subdomains of your own Pages project. |
| 348 | + |
| 349 | +### Edge Caching and Per-Origin Headers |
| 350 | + |
| 351 | +With an allowlist, responses carry the *requesting* origin in `Access-Control-Allow-Origin`. If you cache API responses at the edge (the Cache API or `cf` fetch options), a response stored for one origin can be served to another with the wrong header. Either skip edge caching for allowlisted endpoints, include the origin in the cache key, or cache only responses that carry no per-origin headers. |
| 352 | + |
| 353 | +### Cloudflare Access in Front of the API |
| 354 | + |
| 355 | +Cloudflare Access authenticates requests before they reach your Worker, and preflights carry no credentials -- so Access blocks them and every cross-origin request fails. If your API sits behind Access, enable the Access application setting that bypasses `OPTIONS` requests to the origin, and let the Worker's `cors()` middleware answer them. |
| 356 | + |
| 357 | +## Further Reading |
| 358 | + |
| 359 | +- [MDN: Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) -- the full protocol, header by header |
| 360 | +- [Hono CORS middleware](https://hono.dev/docs/middleware/builtin/cors) -- upstream middleware documentation |
| 361 | +- [Security Standards](./README.md#security-standards) -- the rest of Nerva's security conventions |
0 commit comments