Files
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
CORS
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.
Press Run tests to start the app. Its log appears here.Graded endpoints
The browser's OPTIONS asks before a GET from https://app.example; cors() answers 204 with the permissions, including the max age it may cache them for
A request that will carry Content-Type and X-Api-Key asks for them; the allowed list is answered
An origin not on the list gets a 204 with no Access-Control-Allow-Origin, which the browser reads as a refusal (the response headers in the panel show the difference)
The actual GET carries the allowed origin back, the credentials flag, and the header the page may read
Same middleware, a second allowed origin
The admin app reads back three cats; the count header follows
DELETE is one of the allowed methods; past CORS, the handler’s 404 still carries the origin header
A 204 with the CORS headers a browser needs to accept it
Tom is gone
cors() is scoped to /api/*: health carries no CORS headers, and needs none