2026-10-05 / zSender
@fastify/cors v11 blocks browser DELETE and PATCH
On 2026-09-09 we upgraded our API server to Fastify 5 and moved each Fastify plugin it uses to that plugin's latest major. For the next ten days, seven actions in our dashboard failed: deleting things, editing them, toggling them on or off. Each one showed the same generic error toast. The public API kept working, no test we had could see it, and the server logged no errors. We found the cause on 2026-09-19. It was one changed default in a plugin we had configured with only an origin and a credentials flag.
What changed in v11#
@fastify/cors decides what goes in the Access-Control-Allow-Methods header of a preflight response. If you do not pass methods, it uses a default from its own defaultOptions object. We read that object in the published index.js of each version:
| Version | Published | Fastify range | Default methods |
|---|---|---|---|
| 9.0.1 | 2024-01-31 | 4.x | GET,HEAD,PUT,PATCH,POST,DELETE |
| 10.0.0 | 2024-09-04 | 5.x | GET,HEAD,PUT,PATCH,POST,DELETE |
| 10.1.0 | 2025-02-24 | 5.x | GET,HEAD,PUT,PATCH,POST,DELETE |
| 11.0.0 | 2025-02-27 | 5.x | GET,HEAD,POST |
| 11.3.0 | 2026-07-08 | 5.x | GET,HEAD,POST |
The diff between 10.1.0 and 11.0.0 in index.js is that single line. The v11.0.0 release notes list it under "Breaking Change" and say the default now matches the CORS-safelisted methods, which the Fetch standard defines as GET, HEAD and POST. The notes also show how to get the old behaviour back: pass methods explicitly.
It is worth being exact about what forced what. Fastify 5 did not require v11. Version 9 declares Fastify 4.x, so Fastify 5 needs v10 or later, and v10 supports Fastify 5 with the old broad default. We moved to v11 because the upgrade took the newest major of each plugin, and v11 is where the default narrowed. Pinning v10 would have kept these actions working; so would one line of configuration on v11.
Why nothing failed on the server#
A browser page on one origin that sends a DELETE or PATCH to an API on another origin does not send that request straight away. DELETE, PATCH and PUT are not CORS-safelisted, so the browser first sends a preflight: an OPTIONS request carrying Access-Control-Request-Method: DELETE. The plugin answers that preflight itself, before any route runs, with a 204 and the configured Access-Control-Allow-Methods. Under the v11 default that header says GET,HEAD,POST.
The Fetch standard then says that if the request's method is not in that list and is not a CORS-safelisted method, the preflight returns a network error. The real DELETE is never sent. On a network error, fetch() rejects its promise with a TypeError, so the page gets no response, no status code and no body to read. MDN puts it plainly: specifics about a CORS error are not available to JavaScript, and the browser console is the only place that says what went wrong.
So from the server's side, the only request that ever arrived was an OPTIONS that it answered with a 204, which looks like success. No route handler ran, nothing threw, and there was no error to log. From the page's side, every one of those actions ended in the same opaque failure, which our dashboard turned into a generic toast.
Clients outside a browser were never affected. CORS is a check the browser performs on behalf of a page. A curl command, a server-to-server call or an SDK running in a backend sends the DELETE directly, gets the real response, and never asks for permission first. That is why our public API looked healthy the whole time.
How we found it#
The toast said nothing useful and the logs had no failures in them. What settled it was sending the preflight by hand from outside, the same way a browser would:
curl -i -X OPTIONS https://api.example.com/items/1 \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: DELETE'The response was a 204 with this header:
access-control-allow-methods: GET,HEAD,POSTDELETE was not in the list. Once the header was on screen, the rest followed: the plugin version, the changed default, and the release note that announced it.
The fix#
We now state the methods we use instead of relying on the plugin's default:
import Fastify from 'fastify'
import cors from '@fastify/cors'
const app = Fastify()
await app.register(cors, {
origin: ['https://app.example.com'],
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
})With an array, the plugin joins the values with a comma and a space, so the header reads GET, HEAD, POST, PUT, PATCH, DELETE. Both forms are valid; it matters only if you compare the header as a raw string.
The same curl command is a quick check after any deploy that touches CORS. It shows exactly what a browser will be told.
Test the header, not the status#
No test we had could see this, and the reason applies to any test that uses inject. app.inject() sends requests straight into the server without a browser in the loop, so a DELETE through inject works whatever the CORS configuration says. A test that does send a preflight and checks only for a 204 also passes, because the plugin answers 204 under the v11 default too. The status code is the same in the working and the broken case; only the header differs.
So the test has to read the header. Build the app the way production builds it, send the preflight, and assert that the method you need is allowed:
import { test } from 'node:test'
import assert from 'node:assert/strict'
test('preflight allows DELETE from the app origin', async () => {
const app = await buildApp()
const res = await app.inject({
method: 'OPTIONS',
url: '/items/1',
headers: {
origin: 'https://app.example.com',
'access-control-request-method': 'DELETE',
},
})
assert.equal(res.statusCode, 204)
const allowed = String(res.headers['access-control-allow-methods'] ?? '')
.split(',')
.map((m) => m.trim())
assert.ok(allowed.includes('DELETE'))
})Here buildApp stands for whatever function creates your production server. Against v11 with no methods option this test fails, and with the configuration above it passes. Repeat the check for each non-safelisted method your browser clients send.
A server-side test still cannot prove a browser will accept the response. It can prove what your server tells the browser, which is the part you control and the part that changed.
The general lesson#
Options that change what a protocol peer sees, such as CORS methods, allowed origins, cookie flags or security headers, are worth writing down even when the default happens to be what you want. A default is part of the dependency, not part of your code, and a major version is allowed to change it. When it does, nothing in your repository changes and nothing in your diff shows it. Stating the value turns a silent behaviour change into a line you can read.
Sources#
- @fastify/cors v10.1.0 index.js, default
methodsofGET,HEAD,PUT,PATCH,POST,DELETE - @fastify/cors v11.0.0 index.js, default
methodsofGET,HEAD,POST - @fastify/cors v11.0.0 release notes, the breaking change and how to restore the previous methods
- Fetch standard, CORS-safelisted method
- Fetch standard, CORS-preflight fetch
- Fetch standard, the fetch method, a network error rejects with a
TypeError - MDN, Cross-Origin Resource Sharing