CORS
Let a browser app on another origin call the cats API: answer the preflight, name the origins, methods and headers that are allowed, expose a custom header, and keep the rest of the API untouched.
What you'll learn
- Read what a browser sends before a cross-origin request, the OPTIONS preflight, and what cors() answers
- Configure origin, allowMethods, allowHeaders, exposeHeaders, maxAge and credentials, and see each become a header
- Scope CORS to the routes that need it and understand that an origin not on the list simply gets no permission
The cats API answers any request it receives. A browser is stricter than the API: a page served from https://app.example may not read a response from https://api.example unless the API says it may. That rule, the same-origin policy, protects users from pages that would otherwise call any API the user is signed into. Cross-Origin Resource Sharing is the protocol by which an API grants the exception, and it is entirely a matter of response headers, which is why it is middleware.
What the browser does
For a simple GET the browser sends the request with an Origin header and only lets the page read the response if it carries Access-Control-Allow-Origin naming that origin (or *). For anything else, a DELETE, a POST with JSON, a request with a custom header, the browser first sends a preflight: an OPTIONS request to the same path with Origin, Access-Control-Request-Method and, when custom headers are coming, Access-Control-Request-Headers. Only if the answer permits the method and headers does the real request go out. The API never sees a refused request; the refusal happens in the browser, reading headers that were not there.
cors()
import { cors } from 'hono/cors';
// CORS should be called before the route
app.use('/api/*', cors());
app.use(
'/api2/*',
cors({
origin: 'http://example.com',
allowHeaders: ['X-Custom-Header', 'Upgrade-Insecure-Requests'],
allowMethods: ['POST', 'GET', 'OPTIONS'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
credentials: true,
}),
);
cors() with no options allows every origin, *, and the default methods. It answers the preflight itself, 204 with no body, and never calls next() for it, so no route needs to handle OPTIONS. For every other request it adds the headers and calls next(). Each option is one header:
| Option | Header |
|---|---|
origin | Access-Control-Allow-Origin: a string, an array matched exactly, or a function (origin, c) => string |
allowMethods | Access-Control-Allow-Methods, joined with commas |
allowHeaders | Access-Control-Allow-Headers; empty by default, in which case the requested headers are echoed |
exposeHeaders | Access-Control-Expose-Headers: response headers the page may read beyond the safe few |
maxAge | Access-Control-Max-Age: seconds the browser may cache a preflight |
credentials | Access-Control-Allow-Credentials: true: cookies and Authorization may be sent, which requires a named origin, never * |
With an array of origins the response also carries Vary: Origin, telling caches that the answer depends on who asked. An origin not in the list gets a response with no Access-Control-Allow-Origin at all: not an error status, just silence, and the browser draws the conclusion.
Where it goes
Above the routes, and only on the routes a browser must reach. Registering cors() on everything grants the exception to routes that were never meant for a page. The docs' first line, "CORS should be called before the route", is the ordering rule from Basics again: a route that answers first leaves no chance for the middleware to add its headers, and a preflight would meet a 404.
Your task
Two browser apps, on https://app.example and https://admin.example, need the routes under /api:
- Register
cors()on/api/*allowing exactly those two origins, the methodsGET,POSTandDELETE, the request headersContent-TypeandX-Api-Key, exposingX-Total-Count, with credentials, and a preflight cache of 600 seconds. /healthstays outside: no CORS headers.
Send OPTIONS /api/cats from the request bar with Origin: https://app.example and Access-Control-Request-Method: GET, then the same with Origin: https://evil.example, and compare the response headers.
When it fails
- The preflight answers
404:cors()is registered below the routes, or on the wrong path. It goes above, on/api/*. access-control-allow-originis*on every response: theoriginoption is missing. Withcredentials: truea browser refuses*outright.- The admin app is refused: the second origin is not in the array. Origins are matched exactly, scheme included.
- The page cannot read
X-Total-Countalthough the header is there: it is not inexposeHeaders. Only a handful of headers are readable by default.
Remember
- CORS is response headers a browser reads; the API grants, the browser enforces.
cors()answers theOPTIONSpreflight itself; each option is oneAccess-Control-*header.credentials: trueneeds a named origin; an unlisted origin gets no header, not an error.- Register above the routes, only where a page needs to reach.
Stuck? Show a hint
cors({ origin: [...], allowMethods: [...], allowHeaders: [...], exposeHeaders: [...], maxAge, credentials }) is middleware; register it on '/api/*' above the routes. The preflight is an OPTIONS request the middleware answers itself with 204. The list of origins is matched exactly; an origin not in it gets no Access-Control-Allow-Origin header, which is how a browser learns the answer is no.