Integration · Fastify

CORS in Fastify: setting up @fastify/cors

Fastify has no CORS support of its own: a route that answers a curl request perfectly fine looks, to a browser, like it sends no Access-Control-Allow-Origin header at all. @fastify/cors is the official fix, and registering it is one line. What isn't obvious is that where you register that line relative to your own hooks decides whether a preflight request ever reaches it. We built a Fastify 5 server with an auth check wired in the way most APIs do it, and tested both orders rather than assuming the plugin's docs covered it.

· ~7 min read

What Fastify sends with nothing installed

A bare Fastify 5.12.7 app with a single route and no CORS plugin doesn't define an OPTIONS handler, so a preflight request to it 404s outright:

curl -i -X OPTIONS http://127.0.0.1:3000/api/widgets \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET"

HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8

{"message":"Route OPTIONS:/api/widgets not found","error":"Not Found","statusCode":404}

The actual GET is a different kind of broken. It returns 200 with the real body, no error, nothing wrong from the server's point of view:

curl -i http://127.0.0.1:3000/api/widgets -H "Origin: https://app.example.com"

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8

{"widgets":[]}

A browser reads that same response, finds no Access-Control-Allow-Origin, and blocks the page from seeing it anyway. Neither Fastify nor Express ships CORS handling on its own; Express's answer is the cors package, covered in how to enable CORS in Node.js and Express, and Fastify's is @fastify/cors.

Installing @fastify/cors

The plugin is maintained in the official fastify/fastify-cors repo. The current release, 11.3.1, targets Fastify 5; we tested everything below against that pair.

npm install fastify @fastify/cors
import Fastify from 'fastify'
import cors from '@fastify/cors'

const fastify = Fastify()

await fastify.register(cors, {
  origin: ['https://app.example.com'],
  credentials: true,
})

fastify.get('/api/widgets', async (req, reply) => {
  return { widgets: [] }
})

await fastify.listen({ port: 3000 })

With nothing but origin set, a preflight from an allowed origin comes back 204 with access-control-allow-origin, access-control-allow-credentials, and access-control-allow-methods set, and the real GET picks up the same origin header plus a Vary: Origin. The README's full option list covers more than most setups need: methods (defaults to GET,HEAD,POST), allowedHeaders (reflects whatever the request asked for if you don't set it), exposedHeaders, maxAge, preflightContinue (default false) to hand the preflight response to your own route instead of ending it there, and optionsSuccessStatus for clients that choke on a bare 204. Leave origin unset and it defaults to *, which the plugin accepts, but * can't be paired with credentials: true — browsers reject a wildcard origin on any credentialed request, covered in CORS with cookies and credentials.

The order that breaks preflight

Most real APIs check for a bearer token on every route. We added that as a plain onRequest hook, registered it before @fastify/cors, and sent the same preflight again:

fastify.addHook('onRequest', async (req, reply) => {
  if (!req.headers.authorization) {
    reply.code(401).send({ error: 'missing auth' })
  }
})

await fastify.register(cors, {
  origin: ['https://app.example.com'],
  credentials: true,
})
curl -i -X OPTIONS http://127.0.0.1:3000/api/widgets \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET"

HTTP/1.1 401 Unauthorized
content-type: application/json; charset=utf-8

{"error":"missing auth"}

No access-control-allow-origin, no 204: the auth hook ran before the CORS plugin's own handling ever got a turn, because Fastify runs onRequest hooks in registration order. A browser sending that preflight sees it fail and reports a CORS error in devtools, not a 401 — there's no auth UI to debug, just a request that never reaches the real route. We swapped the order, registering @fastify/cors first and adding the same auth hook after it:

curl -i -X OPTIONS http://127.0.0.1:3000/api/widgets \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET"

HTTP/1.1 204 No Content
vary: Origin, Access-Control-Request-Headers
access-control-allow-origin: https://app.example.com
access-control-allow-credentials: true
access-control-allow-methods: GET,HEAD,POST

Same hook, same missing token, and now the preflight succeeds regardless, because the plugin's internal OPTIONS handling runs before a hook that was registered after it. The real request still gets checked normally — an unauthenticated GET still comes back 401, now with CORS headers attached so the browser actually lets the page read that 401 and show it to the user:

curl -i http://127.0.0.1:3000/api/widgets -H "Origin: https://app.example.com"

HTTP/1.1 401 Unauthorized
vary: Origin
access-control-allow-origin: https://app.example.com
access-control-allow-credentials: true

{"error":"missing auth"}

The fix is registration order, not configuration: register @fastify/cors before any onRequest or preHandler hook that might short-circuit a request, including auth checks, request logging that rejects malformed input, or anything from another plugin. If you can't control that order because the hook comes from a third-party plugin registered earlier in the chain, have that hook explicitly skip OPTIONS requests instead.

strictPreflight rejects a malformed preflight

strictPreflight defaults to true and checks that an OPTIONS request claiming to be a preflight actually looks like one. We sent an OPTIONS with an Origin header but no Access-Control-Request-Method, which is what a browser preflight always includes:

curl -i -X OPTIONS http://127.0.0.1:3000/api/widgets -H "Origin: https://app.example.com"

HTTP/1.1 400 Bad Request
vary: Origin
access-control-allow-origin: https://app.example.com

Invalid Preflight Request

That's the plugin refusing to guess at a request that doesn't carry the header a browser preflight always sets, not a bug. Setting strictPreflight: false turns it into a plain 204 for any OPTIONS request from an allowed origin, which only matters if something other than a browser is sending bare OPTIONS requests against the same route.

Origin values, and the one the README warns about

We sent the same preflight from an origin that isn't in the allowlist: it still comes back 204, but without access-control-allow-origin, so the browser's follow-up request gets blocked exactly where it should.

origin also accepts a RegExp or a function, for matching a pattern instead of listing every origin by hand. The README is specific about the risk: user-controlled input fed into a regular expression used on every request is a path to denial of service if the pattern isn't written carefully, and recommends testing any RegExp option against a tool built for catching exactly that before using it in production.

When @fastify/cors isn't the layer you need

@fastify/cors only adds headers to responses your own Fastify app sends. It does nothing when the browser's problem is a third-party API that doesn't send CORS headers of its own — that's a server you don't control, and no client-side or Fastify-side configuration changes what it answers with, a distinction covered in why am I getting a CORS error?. Since you're already running a Fastify server, the usual fix is a route that calls that third-party API server-side, using Node's built-in fetch, and returns the result; the browser then only ever talks to your own origin, and the CORS setup above covers it completely. A CORS proxy earns its place when there's no backend in the picture at all, which is the case when not to use a CORS proxy walks through in more detail.