Storage and browser security

CORS, Cross-Origin Resource Sharing

Learn how CORS response headers let browser JavaScript read cross-origin responses, how preflight and credentials work, and how to configure Express.

CORS, or Cross-Origin Resource Sharing, is a set of HTTP headers that lets a server say which other origins may read its responses in a browser.

These are four different origins:

  • https://app.example.com
  • https://api.example.com
  • http://app.example.com
  • https://app.example.com:8443

A fetch() or XHR call can send a cross-origin request, but the same-origin policy hides the response from JavaScript. Unless the server returns the right CORS headers.

So the server owns the policy. You can’t fix a missing CORS header from frontend code.

If you’re stuck right now, I built a free CORS debugger that tells you which headers the server needs. The complete rules are in the MDN CORS guide.

What does CORS protect?

CORS controls whether browser JavaScript can read a response. It doesn’t stop the request from reaching the server.

curl or another server can still call your API. CORS is not authentication or authorization.

Images and classic scripts embed without CORS. Web fonts, JavaScript modules, WebGL textures, and Canvas reads follow CORS rules.

Allow a public resource

For public data with no credentials:

Access-Control-Allow-Origin: *

Any origin may now read the response. Never do this for private data.

Allow one origin

To allow one frontend, return its exact origin:

Access-Control-Allow-Origin: https://app.example.com

If the server picks the value from an allowlist, also send:

Vary: Origin

This tells caches the response depends on Origin. See the Access-Control-Allow-Origin reference. Never reflect an incoming Origin without checking the allowlist.

Example with Express

Use the official cors middleware. This route allows one frontend:

const express = require('express')
const cors = require('cors')

const app = express()

const corsOptions = {
  origin: 'https://app.example.com',
}

app.get('/products', cors(corsOptions), (request, response) => {
  response.json([{ id: 1, name: 'Keyboard' }])
})

app.listen(3000)

Same policy for the whole API? Register it once:

app.use(cors(corsOptions))

Registered this way, it also answers preflight requests.

How preflight requests work

A simple request goes out unchecked: GET, HEAD, or POST with only safelisted headers. For POST, the allowed Content-Type values are:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

Anything else, and the browser first asks permission with an OPTIONS request, the preflight. Even a GET triggers one if you add an Authorization header:

OPTIONS /products HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization

The server approves with:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Headers: Authorization

Only then does the browser send the real DELETE.

CORS with cookies

fetch() leaves cookies out of cross-origin requests by default. To include them:

fetch('https://api.example.com/account', {
  credentials: 'include',
})

The server must then return both:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Credentials and Access-Control-Allow-Origin: * don’t mix. CORS also doesn’t override SameSite or Secure, and it doesn’t replace CSRF protection.

Debugging CORS errors

JavaScript gets a generic network error. The browser console has the details. Check:

  • the exact frontend origin
  • Access-Control-Allow-Origin on the response
  • the OPTIONS response for preflighted requests
  • allowed methods and headers
  • credential settings on both sides

Don’t reach for mode: 'no-cors'. It gives you an opaque response with no status, headers, or body. It silences the error, nothing more.

Lesson completed