How to test preflight request

Send the OPTIONS call the browser would send: curl -s -X OPTIONS https://api.example.com/orders -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: DELETE' -D - -o /dev/null. A passing preflight answers 204 or 200 with access-control-allow-methods covering that method, and an allow-headers line covering every header you named.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

The browser sends a preflight before any request that uses PUT, PATCH, DELETE, a custom header, or a JSON content type. Test it whenever the front end starts sending a new header or method, and on every gateway change. A missed preflight shows up as a feature that works for reads and breaks for writes, with a 200 in the server log and nothing in the error tracker, because the blocked request never leaves the browser.

Prerequisites

const http = require('node:http');
const methods = ['GET', 'POST', 'DELETE'];
const headers = ['authorization', 'content-type'];
http.createServer((req, res) => {
  if (req.method === 'OPTIONS') {
    const m = req.headers['access-control-request-method'];
    const h = (req.headers['access-control-request-headers'] || '')
      .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
    if (methods.includes(m) && h.every((x) => headers.includes(x))) {
      res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
      res.setHeader('Access-Control-Allow-Methods', methods.join(', '));
      res.setHeader('Access-Control-Allow-Headers', headers.join(', '));
      res.setHeader('Access-Control-Max-Age', '600');
    }
    res.writeHead(204);
    return res.end();
  }
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end('{"ok":true}');
}).listen(8099, () => console.log('listening on 8099'));

Steps

  1. Step 1.

    Send a complete preflight: the origin, the method the client wants, and the headers it will attach.

    curl -s -X OPTIONS 'https://api.github.com/repos/curl/curl' -H 'Origin: https://h2check.org' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization,content-type' -D - -o /dev/null
    
    HTTP/2 204
    access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, …
    access-control-max-age: 86400
    access-control-allow-headers: Authorization, Content-Type, If-Match, If-Modified-Since, …
    access-control-allow-methods: GET, POST, PATCH, PUT, DELETE
    access-control-allow-origin: *

    POST is in allow-methods and both requested headers are in allow-headers, so the browser proceeds to the real call.

  2. Step 2.

    Ask a different API for a method and a header it has never seen.

    curl -s -X OPTIONS 'https://httpbin.org/get' -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: DELETE' -H 'Access-Control-Request-Headers: x-trace-id' -D - -o /dev/null | grep -i -E 'access-control|^HTTP|^allow'
    
    HTTP/2 200
    allow: OPTIONS, HEAD, GET
    access-control-allow-origin: https://app.example.com
    access-control-allow-credentials: true
    access-control-allow-methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
    access-control-max-age: 3600
    access-control-allow-headers: x-trace-id

    access-control-allow-headers repeats the header that was asked for, and the allow line lists three methods while allow-methods lists six. Read both.

  3. Step 3.

    Send the method the preflight approved and compare the status.

    curl -s -X DELETE 'https://httpbin.org/get' -H 'Origin: https://app.example.com' -D - -o /dev/null | grep -i -E '^HTTP|^allow|access-control'
    
    HTTP/2 405
    allow: HEAD, OPTIONS, GET
    access-control-allow-origin: https://app.example.com
    access-control-allow-credentials: true

    The preflight approved DELETE and the route refuses it. A preflight answers for the CORS layer, never for the handler behind it.

  4. Step 4.

    Run a preflight against a host with no CORS layer and read the status line first.

    curl -s -X OPTIONS 'https://www.cloudflare.com/' -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' -D - -o /dev/null | grep -i -E 'access-control|^HTTP|^allow'
    
    HTTP/2 204
    allow: GET, HEAD, OPTIONS

    A 204 with no access-control-* header is a failed preflight. Success in the log, blocked in the browser.

  5. Step 5.

    Start the reference server with node cors-preflight.js, then ask it for a header that is not on its list.

    curl -s -X OPTIONS 'http://localhost:8099/orders' -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: DELETE' -H 'Access-Control-Request-Headers: x-trace-id' -D - -o /dev/null
    
    HTTP/1.1 204 No Content
    Date: Fri, 11 Sep 2026 18:59:21 GMT
    Connection: keep-alive

    This is a correct refusal: the method was fine, one header was not, so the whole preflight returns without CORS headers.

  6. Step 6.

    Send the real request that the refused preflight was guarding, then stop the server.

    curl -s -X PATCH 'http://localhost:8099/orders' -H 'Origin: https://app.example.com' -D - -o /dev/null
    
    HTTP/1.1 200 OK
    Access-Control-Allow-Origin: https://app.example.com
    Content-Type: application/json

    curl gets 200 because curl enforces nothing. In a browser this request is never sent, which is why a preflight bug leaves no trace in the access log.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 204 with allow-methods covering your method | The preflight passes | Move on to the real request. | | 2xx with no access-control-* header | The preflight fails | The route has no CORS layer on OPTIONS. Check the gateway rule, not the handler. | | allow-methods holds the method, allow-headers misses one | The browser blocks before sending | Add the header name. A single missing name fails the whole preflight. | | access-control-allow-headers equals what you asked for | The server echoes instead of matching | Ask for the list. Echoed headers mean no validation. | | 401 or 403 on the OPTIONS call | Auth runs before the CORS layer | Exempt OPTIONS from authentication. The browser sends no credentials on a preflight. | | Preflight passes and the real call returns 405 | The CORS list and the router disagree | Fix the allow-methods list to match the routes, as in step 3. |

Common mistakes

Sign: The preflight is tested with OPTIONS and an Origin header only, and it looks fine.Cause: Without Access-Control-Request-Method the request is an ordinary OPTIONS, not a preflight. Some servers answer it from a generic OPTIONS handler and never run the CORS code the browser will exercise.
Sign: The login call works and every write fails after a new header is added to the client.Cause: A custom header such as X-Trace-Id turns a simple request into a preflighted one. The allow-headers list was written before the header existed, so the browser blocks the write while the read path keeps working.
Sign: The fix is deployed and the browser keeps blocking the same call.Cause: The previous preflight result is cached for the length of access-control-max-age, up to the browser cap. Test in a fresh profile or with the cache disabled in DevTools before reopening the defect.
Sign: The preflight returns 401 while the same route answers a signed GET.Cause: Authentication middleware sits in front of the CORS middleware. The browser sends a preflight with no Authorization header and no cookies by design, so the auth layer rejects it before any CORS header is written.

Thresholds

access-control-max-age: 7200 is the highest value Chromium honours, Firefox caps at 86400, and a missing header means 5 seconds Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age

What to check next

FAQ

How to test a CORS preflight request without a browser?

The three commands in steps 1, 2 and 5 are the test. Access-Control-Request-Method and Access-Control-Request-Headers are ordinary request headers, so curl sends the same bytes a browser would. Only the enforcement is missing, which is why the headers get read rather than trusted.

Which requests trigger a preflight?

Anything outside GET, HEAD and POST, any custom request header, and POST with a content type other than text/plain, multipart/form-data or application/x-www-form-urlencoded. A JSON body is the common trigger in API testing.

What status code should a preflight return?

Either 204 or 200. The status alone proves nothing, as step 4 shows: the check is whether access-control-allow-methods and access-control-allow-headers cover what you asked for.

Why does the preflight pass and the request still fail?

Two layers answer. The preflight comes from the CORS configuration and the real call comes from the route handler, so a method on the allow list can still hit a 405, as in step 3. Test both in the same run.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate5 minpublished updated Maks Verny