The browser breaks things on purpose.

That sounds obvious until you are staring at a request that works in curl, fails in fetch, and gives you a console error that reads like the browser is being difficult. Most of the time, the browser is enforcing a security rule your server, iframe, cookie, or local dev setup did not account for.

The useful mental model is simple: the web platform protects users by making some things fail at the browser boundary. Your job is to find which boundary you hit before changing app code.

Start by proving which layer blocked it

I split these failures into two buckets:

  • The request did not happen: the browser blocked the request before it reached your server.
  • The request happened, but JavaScript cannot use the result: the server may have responded, but the browser withheld the response from your code.

A request that never reached the server needs a different fix from a response your JavaScript was not allowed to read.

This is the first pass I use in DevTools:

  • Network tab: did the request appear?
  • Status code: did the server return 200, 204, 302, 401, or something else?
  • Initiator: was it your code, an iframe, an image, a script tag, or a preflight?
  • Request headers: check Origin, Cookie, Authorization, Access-Control-Request-Method, and Access-Control-Request-Headers.
  • Response headers: check Access-Control-Allow-Origin, Access-Control-Allow-Credentials, Set-Cookie, Content-Security-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, and Cross-Origin-Embedder-Policy.
  • Console: read it after the Network tab, not before.

The console is good at telling you the rule name. The Network tab is better at telling you what happened.

Common policies that look like bugs

CORS is not API auth

I wrote a separate post on not fixing CORS by disabling CORS. The same debugging rule applies here.

CORS is a browser-enforced read permission for cross-origin responses. It does not protect your API from curl, backend jobs, CLI scripts, or attackers who call the API directly. It controls whether JavaScript running on one origin can read a response from another origin.

MDN’s Origin docs define an origin as the scheme, hostname, and port. A different port or scheme gives you a different origin:

http://localhost:5173
http://localhost:5174
https://localhost:5173
https://app.example.com
https://www.example.com

A request like this can fail even when the endpoint exists:

await fetch("https://api.example.com/projects", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({ name: "Demo" }),
});

That request usually triggers a CORS preflight because it is cross-origin and uses headers/methods outside the simple request path. The browser first sends an OPTIONS request asking whether the real request is allowed.

You can reproduce the preflight:

curl -i -X OPTIONS 'https://api.example.com/projects' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type, authorization'

The response needs to answer that request:

HTTP/2 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: content-type,authorization
Vary: Origin

If cookies are involved, wildcard origins are out:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

That combination does not work for credentialed browser requests. MDN’s Access-Control-Allow-Credentials docs cover the credential side of this. Return the exact allowed origin and include the Vary response header with Origin.

The diagnostic rule: if it works in curl and fails in the browser, check the browser contract, not just the API handler.

Cookies can be set and still not be sent

Cookie bugs are good at looking like auth bugs.

You log in. The response has Set-Cookie. The next API request still looks logged out. It is tempting to debug the session store first. Sometimes the browser rejected the cookie, stored it under a different site context, or decided not to send it.

Check the cookie in DevTools:

  • Was the Set-Cookie response accepted?
  • Is it under the domain you expect?
  • Is Path too narrow?
  • Is Secure present for HTTPS-only cookies?
  • Is HttpOnly present if JavaScript should not read it?
  • Is SameSite right for the navigation or embedded flow?

For cross-site cookies, the practical default is:

Set-Cookie: session=abc; Path=/; HttpOnly; Secure; SameSite=None

SameSite=None requires Secure in modern browsers. Cookies without a SameSite attribute are commonly treated as Lax, which means they may not be sent in the embedded or cross-site request you expected. MDN’s Set-Cookie page is the one I check when I forget the exact attribute behavior.

Also check the frontend call:

await fetch("https://api.example.com/me", {
  credentials: "include",
});

If credentials is missing, fetch will not include cookies on cross-origin requests. If credentials is present but your CORS response does not allow credentials, JavaScript still cannot use the response.

The diagnostic rule: inspect the cookie storage and the request’s Cookie header. Do not infer cookie behavior from the login response alone.

HTTPS pages cannot freely load HTTP resources

Mixed content is another “but the URL works when I open it” trap.

An HTTPS page loading http:// scripts, stylesheets, fonts, iframes, or API calls is not the same as opening that HTTP URL in a tab. The page is secure; the subresource is not. The browser may upgrade the request to HTTPS or block it, depending on the resource type and browser behavior. MDN’s mixed content docs describe the current upgradable/blockable split.

This often appears after moving a site behind HTTPS:

<script src="http://cdn.example.com/widget.js"></script>
<img src="http://images.example.com/logo.png" />

Fix the URLs at the source:

<script src="https://cdn.example.com/widget.js"></script>
<img src="https://images.example.com/logo.png" />

Do not paper over this by telling people to allow insecure content in the browser. That only hides the production problem.

The diagnostic rule: search the generated HTML and runtime config for http://, not just source files.

curl -s https://app.example.com | rg 'http://'

For built frontend apps, also inspect generated assets:

rg 'http://' dist .next build public

CSP turns “it loaded yesterday” into a policy error

Content Security Policy is one of the most useful browser security features and one of the easiest to mistake for random breakage.

You add an analytics script, payment widget, image CDN, iframe, or inline script. The browser blocks it. The app code did not change much, but the page has a policy that says which sources are allowed.

Example:

Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:

This policy says scripts must come from the same origin. A third-party script will be blocked:

<script src="https://analytics.example.com/script.js"></script>

Keep CSP enabled and add the narrow source you need:

Content-Security-Policy: default-src 'self'; script-src 'self' https://analytics.example.com; img-src 'self' data:

For inline scripts, prefer a nonce or hash. Do not add 'unsafe-inline' unless you have decided the tradeoff explicitly.

CSP has a good test mode:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'

That reports violations without enforcing the policy. I use Content-Security-Policy-Report-Only when tightening a real app because it tells me what would break before I break it.

The diagnostic rule: when the console says “Refused to load”, read the effective directive and the blocked URL. CSP errors usually include both.

Iframes have their own permissions

Iframe failures often look like the child app is broken. Sometimes the parent page did not grant the feature.

There are two common places to check.

First, the iframe’s allow attribute:

<iframe
  src="https://checkout.example.com"
  allow="camera; microphone; payment"
></iframe>

Second, the page’s Permissions-Policy header:

Permissions-Policy: camera=(), microphone=(), geolocation=()

That header can disable browser features for the document and nested browsing contexts. If the parent denies camera access, the embedded page cannot fix that by calling getUserMedia() harder.

The same pattern shows up with sandboxed iframes:

<iframe
  src="https://tool.example.com"
  sandbox="allow-scripts allow-forms"
></iframe>

Without the right sandbox tokens, navigation, popups, forms, scripts, or same-origin behavior may be restricted. Sandboxing intentionally removes capabilities until the parent page grants them back.

The diagnostic rule: debug the parent page and iframe attributes before changing the embedded app.

Isolation headers can break popups and embedded resources

Cross-origin isolation is useful for powerful APIs such as SharedArrayBuffer, but the headers are strict. Google’s COOP/COEP explainer is still a useful overview when you need the practical consequence rather than only the header syntax.

The usual set looks like this:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

COOP can separate your top-level page from cross-origin popups. COEP can require embedded cross-origin resources to explicitly opt in with CORS or Cross-Origin-Resource-Policy.

That means a third-party script, worker, image, or wasm file that loaded before may stop loading after you enable isolation. The browser is not confused. You asked it to isolate the page.

Check this in the console:

window.crossOriginIsolated

If it is false, inspect the blocked resource. If it is true and popups or embeds changed behavior, inspect the COOP/COEP headers on the top-level page and the resource headers on the things it embeds.

The diagnostic rule: when you enable isolation, audit every cross-origin script, worker, iframe, and binary resource.

My usual debugging order

When a browser feature looks like a bug, I use this order:

  • Reproduce it in a clean browser profile or private window.
  • Open Network and preserve logs.
  • Check whether the request happened.
  • Check request and response headers before app code.
  • Compare browser behavior with curl, but do not treat curl as proof that browser code should work.
  • Search generated output for stale origins, http:// URLs, and old environment values.
  • Reduce the failing case to one HTML file or one fetch call.
  • Only then change server or frontend code.

Here is the small test page I reach for when I want to remove framework noise:

<!doctype html>
<meta charset="utf-8" />
<button id="run">Run request</button>
<pre id="out"></pre>
<script>
  document.querySelector("#run").addEventListener("click", async () => {
    const out = document.querySelector("#out");

    try {
      const response = await fetch("https://api.example.com/me", {
        credentials: "include",
        headers: {
          "content-type": "application/json",
        },
      });

      out.textContent = `${response.status}\n${await response.text()}`;
    } catch (error) {
      out.textContent = String(error);
      console.error(error);
    }
  });
</script>

Serve it from the same origin as your app or from the origin you are testing:

python3 -m http.server 5173

Then compare the exact Origin header, preflight, cookies, and response headers with your real app.

The browser is doing policy enforcement

These failures feel inconsistent because different browser APIs have different defaults:

  • CORS controls whether browser JavaScript can read cross-origin responses.
  • SameSite controls when cookies are sent in same-site and cross-site contexts.
  • Mixed content protects HTTPS pages from insecure subresources.
  • CSP controls which resources and script execution patterns a page allows.
  • Permissions Policy and iframe attributes control which browser features embedded content can use.
  • COOP and COEP change opener relationships and cross-origin embedding rules.

The common thread is not “browser weirdness”. It is policy enforcement at the browser boundary.

Once I look for the policy first, the bug usually gets smaller.