Browser, HTTP & networking

Fetch, CORS and cookies

What fetch does and does not do, credentials and modes, the same-origin policy and how CORS preflights work, why CORS protects users not servers, cross-origin isolation headers, and every cookie attribute — SameSite, Secure, HttpOnly, prefixes, partitioning — with how Nuxt reads and sets them on both sides.

"The request works in Postman but the browser blocks it" is CORS; "the cookie is set but not sent" is SameSite; "the user is logged in on the server render but not after hydration" is credentials during SSR. These three come up in almost every fullstack interview because every team has lost a day to them.

Know

  • fetch semantics: the promise resolves as soon as headers arrive and does not reject on 4xx/5xx (check response.ok/status); it rejects on network failure, CORS blocks and aborts. Bodies are read once (json(), text(), formData(), arrayBuffer(), or body as a stream). Options: method, headers, body, signal (AbortController), credentials (same-origin default, include, omit), mode (cors default, same-origin, no-cors → opaque response), cache (default, no-store, reload, no-cache, force-cache), redirect, keepalive (outlives the page, for beacons, 64 KB limit), priority. ofetch (Nuxt's $fetch) adds JSON handling, throwing on error status, retries and interceptors.
  • Same-origin policy: an origin is scheme + host + port. Scripts can send cross-origin requests (forms and images always could) but cannot read cross-origin responses unless the server opts in with CORS. Embedding (<img>, <script>, <iframe>) is allowed; reading is not.
  • CORS: the browser sends Origin; the server answers Access-Control-Allow-Origin (a specific origin, or * which is incompatible with credentials), Access-Control-Allow-Credentials: true to allow cookies, Access-Control-Expose-Headers for non-safelisted response headers. Preflight (OPTIONS with Access-Control-Request-Method/-Headers) happens for non-simple requests: methods other than GET/HEAD/POST, custom headers such as Authorization, or Content-Type: application/json (only form types are simple); Access-Control-Allow-Methods/-Headers and Access-Control-Max-Age answer it. Redirects and missing Vary: Origin are classic causes of intermittent failures.
  • CORS protects the user's data in the browser, not the server. A server that relies on CORS for authorisation is open to curl; authentication, CSRF protection and input validation are separate. mode: 'no-cors' yields an opaque response you cannot read and is rarely what you want.
  • Cross-origin isolation: Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy isolate a page from other origins (required for SharedArrayBuffer and high-resolution timers); the crossorigin attribute on <script>/<img> requests CORS mode so error details and canvas reads are allowed; integrity adds subresource integrity.
  • Cookies: set with Set-Cookie: name=value; Max-Age=...; Path=/; Domain=...; Secure; HttpOnly; SameSite=Lax. Secure: HTTPS only. HttpOnly: invisible to document.cookie (session cookies always). SameSite: Strict (never on cross-site requests, including top-level navigation from a link), Lax (default in Chromium; sent on top-level GET navigations, not on cross-site POST or subresources), None (cross-site allowed; requires Secure). Prefixes: __Host- (Secure, no Domain, Path=/) and __Secure-. Partitioned (CHIPS) for third-party cookies keyed by top-level site. Limits: ~4 KB each, a few hundred per domain, sent on every matching request (keep them small). Expires/Max-Age for persistence; session cookies without them.
  • Third-party cookies are blocked by Safari and Firefox and remain under user choice in Chrome after Google abandoned default deprecation (verify current status); design around them with first-party cookies, CHIPS, or server-side integration.
  • Cookies and CSRF: because cookies are attached automatically, a cross-site form post can carry the victim's session; SameSite=Lax/Strict mitigates most of it, and token or origin-header checks close the rest (the security section covers it).
  • In Nuxt: useCookie(name, options) reads and writes on both sides; on the server getCookie/setCookie/deleteCookie from h3; SSR fetches do not carry the browser's cookies unless you forward them (useRequestFetch(), useRequestHeaders(['cookie'])); the Cookie Store API (cookieStore) enables change events, which Nuxt's experimental.cookieStore uses to keep useCookie refs in sync.
  • Sending data on unload: navigator.sendBeacon() or fetch(url, { keepalive: true }) from visibilitychange/pagehide, never unload (which also breaks bfcache).

How it works

Reading a CORS failure from the wire:

OPTIONS /api/orders HTTP/1.1
Origin: https://shop.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
Vary: Origin

Missing Vary: Origin with several allowed origins means a CDN can cache the response for origin A and serve it to origin B, which then fails. Allow-Origin: * with credentials: 'include' fails outright.

CORS in Nitro for a public API, and cookie handling on both sides of a Nuxt app:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/api/public/**': { cors: true, headers: { 'access-control-allow-origin': 'https://partner.example.com', 'access-control-allow-credentials': 'true', 'vary': 'Origin' } },
  },
})
server/api/login.post.ts
export default defineEventHandler(async (event) => {
  const user = await authenticate(await readBody(event))
  setCookie(event, '__Host-session', await seal(user), {
    httpOnly: true,   // invisible to scripts: XSS cannot read it
    secure: true,     // HTTPS only (required by the __Host- prefix)
    sameSite: 'lax',  // sent on top-level navigations, not on cross-site POSTs
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  })
  return { ok: true }
})
app/composables/useApi.ts
// Forward the incoming cookie during SSR so the API sees the same session as the page.
export function useApi() {
  const headers = import.meta.server ? useRequestHeaders(['cookie']) : undefined
  return $fetch.create({ baseURL: '/api', headers, credentials: 'include' })
}
app/composables/useConsent.ts
// A preference cookie the server can read for the first render; not HttpOnly because the client writes it.
export const useConsent = () => useCookie<'granted' | 'denied' | null>('consent', {
  default: () => null,
  sameSite: 'lax',
  secure: true,
  maxAge: 60 * 60 * 24 * 180,
})

Beacons that survive navigation without blocking bfcache:

app/plugins/analytics.client.ts
export default defineNuxtPlugin(() => {
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      navigator.sendBeacon('/api/telemetry/session', JSON.stringify({ path: location.pathname, at: Date.now() }))
    }
  })
})
Gotcha· fetch does not throw on 500

await fetch(url).then(r => r.json()) happily parses an error page as JSON or throws a confusing parse error. Check response.ok first, or use ofetch, which throws a FetchError with status and data for non-2xx responses.

Gotcha· SameSite=Strict on the session cookie

A user who clicks a link to your app from an email arrives without their session cookie (top-level cross-site navigation) and sees the logged-out state. Lax sends the cookie on such GET navigations while still blocking cross-site POSTs; Strict is for cookies that only matter after the user is already inside.

Verify before the interview:

The current status of third-party cookies in Chrome (user-choice model), Safari and Firefox, and CHIPS (Partitioned) support, as of the interview week.

docs ↗

Exercise

Exercise
  • From a page on localhost:3111, fetch an API on another port with Content-Type: application/json; read the preflight in the Network panel, then make it a simple request and watch the preflight disappear.
  • Set a session cookie with SameSite=Strict, open the app from a link in a different site (or a file:// page), and observe the logged-out render; switch to Lax.
  • Remove the cookie forwarding from useApi and diff the server-rendered HTML against the hydrated DOM for a logged-in user.

Be able to say

Be able to say· A request works in curl but the browser says it is blocked by CORS. What is happening?

"The same-origin policy: the browser lets a page send cross-origin requests but refuses to hand the response to the script unless the server opts in. For anything beyond a simple GET or form POST, the browser first sends an OPTIONS preflight with the origin, method and headers it wants to use, and the server must answer with Access-Control-Allow-Origin for that origin, plus allowed methods and headers, and Allow-Credentials if cookies are involved, which also rules out the wildcard origin. Curl skips all of this because there is no origin to protect. So I look at the preflight in the Network panel, fix the server's allow headers, add Vary: Origin if several origins are allowed, and remember that CORS protects the user's browser data, not my API: authentication and CSRF protection are separate concerns."

Be able to say· Which attributes do you set on a session cookie and why?

"HttpOnly so scripts cannot read it and an XSS cannot exfiltrate the session. Secure so it never travels over plain HTTP, and the __Host- prefix to lock it to this host with Path=/ and no Domain. SameSite=Lax so it is sent on top-level navigations, which keeps links from emails working, but not on cross-site POSTs or subresources, which removes most CSRF; Strict only for cookies that matter after the user is inside. A Max-Age matched to the session policy, and a small sealed value rather than data, because every cookie rides on every request. In Nuxt the server sets it with h3's setCookie and the page reads the user through the session API, never through the cookie itself."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.