{
  "$type": "site.standard.document",
  "description": "A practical debugging model for browser security features that look like broken code: CORS, SameSite cookies, mixed content, CSP, iframe permissions, and isolation headers.",
  "path": "/the-web-platform-is-full-of-security-features-that-look-like-bugs/",
  "publishedAt": "2026-06-25T07:25:00.000Z",
  "site": "at://did:plc:bryys25pc2fnagnyxqgsglhd/site.standard.publication/3mn26bjkkmh23",
  "tags": [
    "Web",
    "Techniques"
  ],
  "textContent": "The browser breaks things on purpose.\n\nThat 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.\n\nThe 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.\n\nSTART BY PROVING WHICH LAYER BLOCKED IT\n\nI split these failures into two buckets:\n\n * The request did not happen: the browser blocked the request before it reached your server.\n * The request happened, but JavaScript cannot use the result: the server may have responded, but the browser withheld the response from your code.\n\nA request that never reached the server needs a different fix from a response your JavaScript was not allowed to read.\n\nThis is the first pass I use in DevTools:\n\n * Network tab: did the request appear?\n * Status code: did the server return 200, 204, 302, 401, or something else?\n * Initiator: was it your code, an iframe, an image, a script tag, or a preflight?\n * Request headers: check Origin, Cookie, Authorization, Access-Control-Request-Method, and Access-Control-Request-Headers.\n * 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.\n * Console: read it after the Network tab, not before.\n\nThe console is good at telling you the rule name. The Network tab is better at telling you what happened.\n\nCOMMON POLICIES THAT LOOK LIKE BUGS\n\nCORS IS NOT API AUTH\n\nI wrote a separate post on not fixing CORS by disabling CORS. The same debugging rule applies here.\n\nCORS 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.\n\nMDN's Origin docs define an origin as the scheme, hostname, and port. A different port or scheme gives you a different origin:\n\nhttp://localhost:5173\nhttp://localhost:5174\nhttps://localhost:5173\nhttps://app.example.com\nhttps://www.example.com\n\nA request like this can fail even when the endpoint exists:\n\nawait fetch(\"https://api.example.com/projects\", {\n  method: \"POST\",\n  headers: {\n    \"content-type\": \"application/json\",\n    authorization: `Bearer ${token}`,\n  },\n  body: JSON.stringify({ name: \"Demo\" }),\n});\n\nThat 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.\n\nYou can reproduce the preflight:\n\ncurl -i -X OPTIONS 'https://api.example.com/projects' \\\n  -H 'Origin: https://app.example.com' \\\n  -H 'Access-Control-Request-Method: POST' \\\n  -H 'Access-Control-Request-Headers: content-type, authorization'\n\nThe response needs to answer that request:\n\nHTTP/2 204\nAccess-Control-Allow-Origin: https://app.example.com\nAccess-Control-Allow-Methods: GET,POST,OPTIONS\nAccess-Control-Allow-Headers: content-type,authorization\nVary: Origin\n\nIf cookies are involved, wildcard origins are out:\n\nAccess-Control-Allow-Origin: *\nAccess-Control-Allow-Credentials: true\n\nThat 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.\n\nThe diagnostic rule: if it works in curl and fails in the browser, check the browser contract, not just the API handler.\n\nCOOKIES CAN BE SET AND STILL NOT BE SENT\n\nCookie bugs are good at looking like auth bugs.\n\nYou 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.\n\nCheck the cookie in DevTools:\n\n * Was the Set-Cookie response accepted?\n * Is it under the domain you expect?\n * Is Path too narrow?\n * Is Secure present for HTTPS-only cookies?\n * Is HttpOnly present if JavaScript should not read it?\n * Is SameSite right for the navigation or embedded flow?\n\nFor cross-site cookies, the practical default is:\n\nSet-Cookie: session=abc; Path=/; HttpOnly; Secure; SameSite=None\n\nSameSite=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.\n\nAlso check the frontend call:\n\nawait fetch(\"https://api.example.com/me\", {\n  credentials: \"include\",\n});\n\nIf 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.\n\nThe diagnostic rule: inspect the cookie storage and the request's Cookie header. Do not infer cookie behavior from the login response alone.\n\nHTTPS PAGES CANNOT FREELY LOAD HTTP RESOURCES\n\nMixed content is another \"but the URL works when I open it\" trap.\n\nAn 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.\n\nThis often appears after moving a site behind HTTPS:\n\n<script src=\"http://cdn.example.com/widget.js\"></script>\n<img src=\"http://images.example.com/logo.png\" />\n\nFix the URLs at the source:\n\n<script src=\"https://cdn.example.com/widget.js\"></script>\n<img src=\"https://images.example.com/logo.png\" />\n\nDo not paper over this by telling people to allow insecure content in the browser. That only hides the production problem.\n\nThe diagnostic rule: search the generated HTML and runtime config for http://, not just source files.\n\ncurl -s https://app.example.com | rg 'http://'\n\nFor built frontend apps, also inspect generated assets:\n\nrg 'http://' dist .next build public\n\nCSP TURNS \"IT LOADED YESTERDAY\" INTO A POLICY ERROR\n\nContent Security Policy is one of the most useful browser security features and one of the easiest to mistake for random breakage.\n\nYou 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.\n\nExample:\n\nContent-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:\n\nThis policy says scripts must come from the same origin. A third-party script will be blocked:\n\n<script src=\"https://analytics.example.com/script.js\"></script>\n\nKeep CSP enabled and add the narrow source you need:\n\nContent-Security-Policy: default-src 'self'; script-src 'self' https://analytics.example.com; img-src 'self' data:\n\nFor inline scripts, prefer a nonce or hash. Do not add 'unsafe-inline' unless you have decided the tradeoff explicitly.\n\nCSP has a good test mode:\n\nContent-Security-Policy-Report-Only: default-src 'self'; script-src 'self'\n\nThat 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.\n\nThe diagnostic rule: when the console says \"Refused to load\", read the effective directive and the blocked URL. CSP errors usually include both.\n\nIFRAMES HAVE THEIR OWN PERMISSIONS\n\nIframe failures often look like the child app is broken. Sometimes the parent page did not grant the feature.\n\nThere are two common places to check.\n\nFirst, the iframe's allow attribute:\n\n<iframe\n  src=\"https://checkout.example.com\"\n  allow=\"camera; microphone; payment\"\n></iframe>\n\nSecond, the page's Permissions-Policy header:\n\nPermissions-Policy: camera=(), microphone=(), geolocation=()\n\nThat 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.\n\nThe same pattern shows up with sandboxed iframes:\n\n<iframe\n  src=\"https://tool.example.com\"\n  sandbox=\"allow-scripts allow-forms\"\n></iframe>\n\nWithout 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.\n\nThe diagnostic rule: debug the parent page and iframe attributes before changing the embedded app.\n\nISOLATION HEADERS CAN BREAK POPUPS AND EMBEDDED RESOURCES\n\nCross-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.\n\nThe usual set looks like this:\n\nCross-Origin-Opener-Policy: same-origin\nCross-Origin-Embedder-Policy: require-corp\n\nCOOP 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.\n\nThat 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.\n\nCheck this in the console:\n\nwindow.crossOriginIsolated\n\nIf 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.\n\nThe diagnostic rule: when you enable isolation, audit every cross-origin script, worker, iframe, and binary resource.\n\nMY USUAL DEBUGGING ORDER\n\nWhen a browser feature looks like a bug, I use this order:\n\n * Reproduce it in a clean browser profile or private window.\n * Open Network and preserve logs.\n * Check whether the request happened.\n * Check request and response headers before app code.\n * Compare browser behavior with curl, but do not treat curl as proof that browser code should work.\n * Search generated output for stale origins, http:// URLs, and old environment values.\n * Reduce the failing case to one HTML file or one fetch call.\n * Only then change server or frontend code.\n\nHere is the small test page I reach for when I want to remove framework noise:\n\n<!doctype html>\n<meta charset=\"utf-8\" />\n<button id=\"run\">Run request</button>\n<pre id=\"out\"></pre>\n<script>\n  document.querySelector(\"#run\").addEventListener(\"click\", async () => {\n    const out = document.querySelector(\"#out\");\n\n    try {\n      const response = await fetch(\"https://api.example.com/me\", {\n        credentials: \"include\",\n        headers: {\n          \"content-type\": \"application/json\",\n        },\n      });\n\n      out.textContent = `${response.status}\\n${await response.text()}`;\n    } catch (error) {\n      out.textContent = String(error);\n      console.error(error);\n    }\n  });\n</script>\n\nServe it from the same origin as your app or from the origin you are testing:\n\npython3 -m http.server 5173\n\nThen compare the exact Origin header, preflight, cookies, and response headers with your real app.\n\nTHE BROWSER IS DOING POLICY ENFORCEMENT\n\nThese failures feel inconsistent because different browser APIs have different defaults:\n\n * CORS controls whether browser JavaScript can read cross-origin responses.\n * SameSite controls when cookies are sent in same-site and cross-site contexts.\n * Mixed content protects HTTPS pages from insecure subresources.\n * CSP controls which resources and script execution patterns a page allows.\n * Permissions Policy and iframe attributes control which browser features embedded content can use.\n * COOP and COEP change opener relationships and cross-origin embedding rules.\n\nThe common thread is not \"browser weirdness\". It is policy enforcement at the browser boundary.\n\nOnce I look for the policy first, the bug usually gets smaller.",
  "title": "The Web Platform Is Full Of Security Features That Look Like Bugs"
}