InteractiveFrameworks

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:

OptionHeader
originAccess-Control-Allow-Origin: a string, an array matched exactly, or a function (origin, c) => string
allowMethodsAccess-Control-Allow-Methods, joined with commas
allowHeadersAccess-Control-Allow-Headers; empty by default, in which case the requested headers are echoed
exposeHeadersAccess-Control-Expose-Headers: response headers the page may read beyond the safe few
maxAgeAccess-Control-Max-Age: seconds the browser may cache a preflight
credentialsAccess-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:

  1. Register cors() on /api/* allowing exactly those two origins, the methods GET, POST and DELETE, the request headers Content-Type and X-Api-Key, exposing X-Total-Count, with credentials, and a preflight cache of 600 seconds.
  2. /health stays 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-origin is * on every response: the origin option is missing. With credentials: true a 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-Count although the header is there: it is not in exposeHeaders. 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 the OPTIONS preflight itself; each option is one Access-Control-* header.
  • credentials: true needs 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.