CORS Preflight failures are easy to misread in browser automation. A 4xx on an OPTIONS request can mean a real configuration bug, or it can be noise from a proxy, a missing Origin header, a redirect, or a test that is asserting the wrong layer of the stack.

The practical goal is not to make every cross-origin request pass. It is to test CORS preflight failures in browser automation in a way that proves the failure is real, reproducible, and tied to the browser’s fetch path, not your test harness.

A useful rule: if you cannot explain why the browser sent an OPTIONS request, you do not yet have a trustworthy CORS failure test.

CORS preflight, in one sentence

A CORS preflight is a browser-issued OPTIONS request that checks whether the real cross-origin request is allowed before the browser sends it. It usually appears when the request is not “simple” under the Fetch standard, for example because it uses Authorization, Content-Type: application/json, or custom headers.

The browser compares the response headers, especially Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers, against the actual request. If the check fails, the browser blocks the real request. MDN’s CORS guide is the best quick reference for the browser-side rules, while the Fetch standard defines the request and response processing model.

  • MDN CORS overview: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
  • Fetch standard: https://fetch.spec.whatwg.org/

What you are trying to prove

There are three different outcomes that often get collapsed into one flaky “4xx” assertion:

  1. The preflight never happened. The request was simple, cached, redirected, or intercepted before reaching the server.
  2. The preflight happened and the server rejected it. This is the real CORS configuration bug you want to catch.
  3. The browser blocked the request for a reason that is not a server 4xx. For example, missing headers, credentialed request mismatch, or a proxy rewriting the origin.

If your test only checks “some request got a 4xx,” it cannot distinguish these cases.

The four checks that separate signal from noise

1) Confirm the browser really sent a preflight

Do not assume every failed cross-origin fetch began with OPTIONS. In browser automation, inspect the network log and filter by method.

For a failure test, you usually want to assert all of these:

  • an OPTIONS request was sent
  • the request included the expected Origin
  • the server returned the exact CORS headers you expect, or omitted one on purpose for a negative case
  • the actual request was blocked or continued exactly as intended

Playwright makes this straightforward:

import { test, expect } from '@playwright/test';
test('captures the preflight request', async ({ page }) => {
  const requests: string[] = [];

  page.on('request', req => {
    if (req.method() === 'OPTIONS') requests.push(req.url());
  });

  await page.goto('https://app.example.test');
  await page.click('button#save');

  expect(requests).toContain('https://api.example.test/profile');
});

If you only have access to the application response, this test is too shallow. You need browser-level visibility into the network layer.

2) Verify the request headers, not just the status code

A valid preflight failure test should inspect request headers such as:

  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers

These are the browser’s signal to the server about the real request it intends to make. If the header set changes, the meaning of the test changes.

A common example is a request that includes Authorization. That often triggers preflight, but only if the browser sees the request as cross-origin and non-simple. If your automation adds or removes a header in a helper, the test may stop exercising the same path.

3) Distinguish CORS rejection from transport failure

A network failure is not the same as a CORS failure.

  • A DNS or proxy issue may prevent the request from reaching the server.
  • A TLS failure may stop the connection before any CORS headers are exchanged.
  • A 302 redirect may move the request to a URL with different origin semantics.

If you want a negative CORS test, make the server respond cleanly with the wrong CORS headers instead of letting the environment fail first. That keeps the failure about policy, not connectivity.

4) Treat credentialed requests as a separate case

Credentialed cross-origin requests, for example those with cookies or fetch(..., { credentials: 'include' }), have stricter rules. The server cannot use Access-Control-Allow-Origin: * with credentials. It must echo a specific origin and include Access-Control-Allow-Credentials: true when appropriate.

That means you should test these separately:

  • anonymous cross-origin fetch
  • credentialed cross-origin fetch
  • preflighted request with custom headers

Do not combine all three into one broad “CORS test.” It becomes hard to tell which rule failed.

A minimal browser automation pattern that is hard to fake

The safest pattern is a real browser page that triggers a real fetch, paired with network observation and a controlled test backend.

Example: trigger a preflight with fetch

await page.evaluate(async () => {
  try {
    await fetch('https://api.example.test/profile', {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'X-Test-Run': 'cors-preflight'
      },
      body: JSON.stringify({ name: 'Ada' }),
      credentials: 'include'
    });
  } catch (err) {
    // expected in a negative CORS test
  }
});

This is useful because it drives the browser’s fetch implementation, not a mocked HTTP client in Node. If you test only with a server-side HTTP library, you are not testing browser CORS behavior.

Example: assert the browser saw the correct preflight failure

const preflight = page.waitForRequest(req => req.method() === 'OPTIONS');
const response = page.waitForResponse(resp => resp.request().method() === 'OPTIONS');
await page.click('button#submit');
const [req, res] = await Promise.all([preflight, response]);
expect(req.headers()['origin']).toBe('https://app.example.test');
expect(res.status()).toBe(403);

That pattern still needs care. A 403 on OPTIONS is not always the right failure shape, because some servers intentionally return 204 with missing headers instead. Match the server behavior you actually run in staging or test.

The failure modes worth checking explicitly

Origin mismatch

This is the simplest and most valuable negative case. The server should allow only known origins, not a wildcard when credentials are involved.

Check for:

  • wrong scheme, such as http:// vs https://
  • wrong subdomain
  • missing port in local development
  • environment-specific origin lists that drift between dev and CI

If local and CI origins differ, encode both intentionally instead of relying on localhost assumptions.

Missing allowed headers

If the request sends X-Test-Run, the preflight response must include it in Access-Control-Allow-Headers.

This is especially easy to break when a new frontend header is added but the backend allowlist is not updated.

Method not allowed

A request using PATCH or DELETE will preflight differently from GET or POST. Make sure the test covers the exact method used by the application, not a friendlier substitute.

Redirects during preflight

Redirects are a frequent source of confusion. A redirect can change the origin or alter what the browser considers the final request path. When a preflight goes through a proxy, you may see the proxy response instead of the origin server response. If the test is supposed to verify CORS policy, avoid redirect chains in the test environment.

Proxy-induced failures

A reverse proxy can strip or rewrite:

  • Origin
  • Access-Control-Allow-*
  • Vary: Origin
  • host and scheme information

If your app sits behind a gateway, test one path that reaches the app directly and one that goes through the proxy. Otherwise you may end up debugging the proxy as if it were the application.

If the same endpoint passes locally and fails only in CI, inspect the proxy and origin rewriting before changing the app code.

A practical debugging sequence

When a preflight test fails, use this sequence before you change assertions:

  1. Capture the OPTIONS request and its headers.
  2. Confirm the request URL and origin are what you expected.
  3. Compare the response headers to the browser’s CORS requirements.
  4. Check whether a proxy, CDN, or local dev server rewrote the response.
  5. Re-run the exact test in a controlled environment with the same origin pair.

This order matters because the response body is often irrelevant. For CORS, the headers are the contract.

When to use browser automation, and when not to

Browser automation is the right layer when you want to verify that the page can or cannot make a cross-origin request under real browser rules.

It is not the best layer when you only need to validate server policy. For that, a direct HTTP test against the preflight endpoint is usually faster and easier to isolate. That server-level test should still model the browser headers accurately, but it does not need a full browser.

A good split is:

  • API or integration tests for header policy and status codes
  • browser automation for the actual frontend flow and browser enforcement

That split keeps your browser suite smaller and reduces flake from incidental network noise.

Decision guide for your suite design

Situation Best layer Why
Backend CORS allowlist changed API/integration test Faster, stable, header-focused
Frontend button triggers cross-origin save Browser automation Verifies the real user flow
Credentialed cross-origin request Browser automation + network assertion Browser rules matter more here
Proxy or CDN suspected Environment test with captured headers Need to see rewritten origin or headers
Simple regression on allowed origin API/integration test Avoids browser overhead

A small checklist for stable assertions

Before you commit a CORS preflight test, check that it answers these questions:

  • Which request method triggers preflight?
  • Which headers make it non-simple?
  • Is the origin fixed in the test environment?
  • Are credentials intentionally included or excluded?
  • Is the failure supposed to happen at the proxy, the origin, or the browser?
  • Are you asserting a header contract, not just a status code?

If you cannot answer one of those, the test will likely become brittle the next time a deployment or proxy changes.

Not the best fit if…

This approach is not the best fit if you only want a smoke check that “the API is up.” CORS preflight tests are about browser policy and cross-origin behavior, not generic uptime.

It is also not the best fit if your app never makes cross-origin requests in production. In that case, add simpler integration coverage where the actual risk lives.

FAQ

Why does a CORS test fail with a 4xx even when the API works in Postman?

Because Postman is not enforcing browser CORS rules. A browser checks the preflight response headers before sending the real request.

Should I assert on the OPTIONS response body?

Usually no. The important contract is the headers, not the body. The body is often empty for preflight responses.

Is a 403 always the right response for a failed preflight?

No. Some systems return 204 with missing or mismatched CORS headers, and the browser blocks the request. Test the policy outcome you actually want, not a specific status code unless your backend contract requires it.

How do I know if the proxy is causing the failure?

Compare the request and response headers at the browser, proxy, and origin layers. If the origin is correct in one path and rewritten in another, the proxy is part of the problem.

Can I test CORS without a browser?

You can test server header policy without a browser, but you cannot fully verify browser enforcement without one. Use both layers when the risk is high.

Bottom line

To test CORS preflight failures in browser automation without flaking on every 4xx, make the test prove a browser-level rule: a specific cross-origin request, with specific headers, from a specific origin, produced a specific preflight outcome. If the test cannot show that chain clearly, it is probably asserting environment noise instead of a real CORS bug.