Browser security boundaries

Configure CORS narrowly

Allow only the browser origins, methods, headers, and credential behavior an API actually needs.

CORS tells browsers which other origins may read a response. That’s the whole job. It is not authentication. It does nothing for curl, a mobile app, or a script on a server, because those clients ignore the headers entirely.

I say this first because people reach for CORS to “protect the API”. It can’t. It only decides what a browser hands to JavaScript on another origin.

The pattern that leaks data

The dangerous shape is reflecting whatever Origin header the request carried and allowing credentials at the same time:

// Vulnerable: echoes the caller's origin and allows cookies
app.use((req, res, next) => {
  res.set('Access-Control-Allow-Origin', req.headers.origin)
  res.set('Access-Control-Allow-Credentials', 'true')
  next()
})

Any page, on any site, can now fetch() your API with the user’s cookies and read the private response. A signed-in user visits a hostile page and their account data is gone. This is the CORS bug I see most often, and it usually starts as a shortcut during development.

Allow one origin at a time

The safe version keeps an explicit allowlist and answers with one exact origin. When the header changes per caller, add Vary: Origin too, so a cache doesn’t serve one origin’s response to another:

const allowed = new Set([
  'https://app.flaviocopes.com',
  'http://localhost:3000',
])

app.use((req, res, next) => {
  const origin = req.headers.origin
  if (allowed.has(origin)) {
    res.set('Access-Control-Allow-Origin', origin)
    res.set('Access-Control-Allow-Credentials', 'true')
    res.set('Vary', 'Origin')
  }
  next()
})

An unlisted origin gets no Access-Control-Allow-Origin header at all. The request still reaches your server, but the browser refuses to expose the response. You can see the difference with curl:

curl -si -H 'Origin: https://evil.example' https://api.flaviocopes.com/account | grep -i access-control

Nothing prints. Repeat with Origin: https://app.flaviocopes.com and you get the two headers back.

Never return Access-Control-Allow-Origin: * together with credentials. The browser refuses that combination anyway, and wanting it is a sign the allowlist is missing.

Keep development origins explicit

localhost:3000 in the list above is fine, because it’s written down. What is not fine is a wildcard “just for dev” that escapes into production unnoticed. Keep local origins in the same explicit list, or load them from an environment variable that production never sets.

Try this on your own project: send a preflight OPTIONS and a credentialed GET from the production origin, from localhost, and from an origin you did not list. Save the response headers for each. The unlisted page must be unable to read the protected response, and your server log should still show that its request arrived.

Lesson completed