AuthGENERALSPEC-EVOLVINGBROWSER-SPECIFIC

Cookies vs Script-Readable Tokens

A genuine trade-off with no universal winner: unreadable-but-automatic against readable-but-explicit, and the attributes that decide what each one actually costs you.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Where should a credential live in the browser, and what does each choice give an attacker who gets a foothold?

The user intent

A person signs in once and expects the product to keep working — in this tab, in the next tab, after a reload, tomorrow morning — without being asked again and without their account being taken over by a page they happened to visit.

The obvious build

Cookies feel old and awkward, and the API wants a bearer token anyway. Put the token in localStorage, read it on boot, attach it to every request as an Authorization header. Simple, works everywhere, no CSRF to think about.

Why it breaks

That last clause is true and it is the whole argument for this design — but it trades one exposure for another rather than removing exposure. Any script that executes in your origin can read Web Storage, and it does not need the user to do anything (Cross-Site Scripting).

How it breaks in a real browser
  • That last clause is true and it is the whole argument for this design — but it trades one exposure for another rather than removing exposure. Any script that executes in your origin can read Web Storage, and it does not need the user to do anything (Cross-Site Scripting).
  • The mirror-image mistake is just as common: "cookies, therefore secure". A cookie without Secure travels in cleartext to any non-HTTPS URL on the host, and a cookie without a considered SameSite is attached to requests your site did not initiate (Cross-Site Request Forgery).
  • The localStorage version has to survive its own bootstrap. The token is read synchronously at boot, so the first request cannot go out until that runs, and every place that forgets the header gets an unauthenticated response that looks like an expiry (localStorage and sessionStorage).
  • Neither choice is decided in isolation. Whether the API is same-site or cross-site, whether there is a BFF, whether a native app shares the backend, and whether the credential must survive a reload all change the answer (Backend for Frontend).
  • "No CSRF to think about" holds only while nothing is sent automatically. Add one cookie-authenticated endpoint — a file download, a legacy route, an SSR page — and CSRF is back for that endpoint alone, which is where it is least likely to be defended.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A cookie is storage the browser attaches to requests on its own, by matching the request against the cookie's Domain, Path, Secure and SameSite attributes. Your code is not involved, which is simultaneously its best and worst property.
  • HttpOnly removes the cookie from document.cookie entirely. Script in your own origin cannot read it — not to steal it, and not to inspect it either. Exfiltration by injected script stops being possible; using the credential from injected script does not, because the browser still attaches it (Cross-Site Scripting).
  • Secure restricts the cookie to secure-context requests. SameSite restricts it by the relationship between the site making the request and the site the cookie belongs to: Strict withholds it on any cross-site request including top-level navigation, Lax allows it on top-level GET navigations, None sends it always and is only accepted together with Secure.
  • Domain widens the cookie to subdomains — omit it and the cookie stays host-only, which is almost always what you want. Path narrows it, and is a scoping convenience rather than a security boundary, because same-origin script can read across paths anyway (Cookies).
  • A script-readable token is an ordinary value in an ordinary place: Web Storage, IndexedDB, a non-HttpOnly cookie, or a JavaScript variable. Nothing attaches it automatically, so your code must — which is exactly why a cross-site request forged by another page carries no credential.
  • A token held only in a module-scoped variable is unreadable by other origins, unreadable from storage, and gone on reload. Products that use it pair it with a long-lived HttpOnly refresh cookie: the durable credential is unreadable, and the readable credential is short-lived (Session Expiry and the Refresh Race).

What this makes the browser do

And which of it is avoidable.

  • Cookie-jar matching on every request, including subresource requests you did not write — images, stylesheets, beacons. The browser evaluates attributes for all of them.
  • A CORS preflight for the header approach whenever the request is cross-origin, because Authorization is not a CORS-safelisted header. That is a full extra round trip before the real one, on every distinct request shape (CORS).
  • Cross-origin cookie sending requires credentials: 'include' on the fetch and a matching Access-Control-Allow-Credentials on the response, and the server may not answer with a wildcard origin. Getting this wrong produces a request that succeeds with no credential attached.
  • Synchronous main-thread work for localStorage reads and writes. Small for one token; not small if the auth layer writes on every response (localStorage and sessionStorage).
  • Storage eviction and partitioning are browser policy, not yours. State can be cleared under pressure or in restricted contexts, so no client store is a durable place for anything that cannot be re-derived (Storage Security and Durability).

The same properties, in a table that does not pick a winner

Every row below is a property somebody has cited as the reason their choice is the correct one. Read down a column and each option looks strong; read across a row and it becomes clear that the columns are trading, not competing. The rows that matter for your application are decided by your topology and your threat model, not by preference.

The one asymmetry worth naming: exfiltration and forgery are not symmetric in consequence. A stolen long-lived credential is an account takeover that persists; a forged request is one action, bounded by what that endpoint does. That asymmetry is why "unreadable and automatic, plus a CSRF defence" is the most common good answer — but it is a tendency, not a rule, and a cross-site topology can overturn it.

Property`HttpOnly` cookieToken in Web StorageToken in memory only
Readable by injected scriptNo — the browser hides it from document.cookieYes — one line of script reads itYes, if the script can reach the module scope holding it
Sent automaticallyYes, by attribute matchingNo — your code attaches itNo — your code attaches it
CSRF exposureYes; needs SameSite plus a token or origin checkNone by defaultNone by default
Survives reloadYes, until it expiresYes, until cleared or evictedNo — this is the point of it
Shared with other tabsYes, one jar per profileYes, one store per originNo — each document has its own
Works cross-siteOnly with SameSite=None; Secure, and browsers increasingly restrict itYes, on an explicit headerYes, on an explicit header
Cost per cross-origin requestNone extra beyond CORS credentials setupA preflight, because Authorization is not safelistedA preflight, same reason
RevocationServer-side; clearing the cookie is only cosmeticServer-side; deleting the value is only cosmeticServer-side; the value dies on unload regardless
Typical leak pathOverly wide Domain, missing Secure, a subdomain takeoverInjected script, a compromised dependency, a devtools-savvy extensionInjected script only, and only while the tab is open

The attributes are the control surface

SPEC-EVOLVINGWhat a browser does when SameSite is absent, and how long it honours a cookie set from script, have both changed and differ between engines. This section teaches the attributes and the reasoning; the current defaults belong in your own verification, not in a lesson that will be read next year.

If you choose cookies, the attributes are the design. A cookie with none of them set is a value the browser will send widely, over any scheme, to anything that matches the host — which is the configuration most likely to appear when nobody made a decision. Set them explicitly even when a default currently agrees with you, because defaults in this area have been moving for years and continue to.

The __Host- prefix is worth knowing: a cookie whose name starts with it is only accepted when it is Secure, has no Domain, and has Path=/. It turns a set of conventions you would otherwise have to review by hand into something the browser refuses to accept incorrectly — a rare case of the platform enforcing your policy for you.

  • `HttpOnly` — removes the value from script. Prevents theft, not use.
  • `Secure` — restricts to secure contexts. There is no modern reason to omit it.
  • `SameSite`Strict for pure session credentials where arriving from an external link may re-prompt; Lax for most; None only with Secure and only when cross-site is a genuine requirement.
  • `Domain` — omit it. Adding it grants every subdomain access, permanently, including ones added later (Cookies).
  • `Path` — scoping convenience only; same-origin script is not constrained by it.
  • `Max-Age` / `Expires` — absent means a session cookie, which browsers may still restore with a restored tab. Do not treat it as a guaranteed erase.
A session cookie whose attributes were all decided on purpose
1HTTP/1.1 200 OK
2Set-Cookie: __Host-session=<opaque-id>;
3 HttpOnly; # script cannot read it — exfiltration off the table
4 Secure; # never sent over an insecure request
5 SameSite=Lax; # withheld from cross-site subresource requests
6 Path=/; # required by the __Host- prefix
7 Max-Age=<seconds> # a lifetime the server chose and can shorten
8# No Domain attribute: host-only, so no subdomain sees it.
9# The value is an opaque id, not a payload: the session lives server-side,
10# so revoking it is a delete rather than a hope that the client cooperates.
11
12# The state-changing request that uses it still carries a second factor:
13POST /api/orders/42/cancel
14Cookie: __Host-session=<opaque-id>
15X-CSRF-Token: <token the page read from a same-origin source>
16Origin: https://app.example.com

Two things are load-bearing and easy to miss: the absent Domain, which keeps the cookie off every subdomain, and the CSRF token, which is required precisely because the cookie is sent automatically. SameSite narrows the window; it does not close the topic.

Choosing, without pretending there is a winner

The decision has three real inputs: whether the API is same-site with the app, whether anything other than this web app consumes the same backend, and how much you are prepared to invest in containing script injection. Everything else is preference.

Notice that the strongest option is a combination rather than a column: an HttpOnly refresh credential the client cannot read, plus a short-lived access token the client holds in memory. Injected script gets, at worst, a token that expires soon and cannot outlive the tab. It costs you a refresh flow — which is the next lesson, and it has a race in it (Session Expiry and the Refresh Race).

Where should this credential live?

Given this application's topology and threat model, which credential design is right?

Session cookie: `HttpOnly`, `Secure`, `SameSite`, host-only

when The API is same-site with the app, and the browser is the only client that matters. The default answer for a classic web application, server-rendered or not.

cost CSRF is now a permanent obligation on every state-changing endpoint, and cross-site integrations later will be awkward or impossible (Cross-Site Request Forgery).

Short-lived token in memory + `HttpOnly` refresh cookie

when You need an explicit bearer token for a cross-site or multi-client API, and you want the durable credential out of script's reach.

cost A refresh endpoint, a deduplicated refresh race, a reload path where the app boots credential-less, and CSRF protection on the refresh endpoint itself.

Token in Web Storage

when A credential must survive reload, cannot be a cookie because the API is on another site with no cookie access, and the lifetime is genuinely short.

cost Any script execution in your origin is an account takeover. Only defensible with a strict CSP, a controlled dependency surface, and a short expiry (Content Security Policy).

Token in `sessionStorage`

when Per-tab isolation is a feature — an admin console, an impersonation session, a shared workstation.

cost A new tab is logged out, which users read as a bug; still fully script-readable within the tab (localStorage and sessionStorage).

No client credential: a same-site BFF holds it

when A third-party API needs a token the browser should never see. The browser gets a plain session cookie; the BFF exchanges it.

cost A service to build, deploy and operate, and a second network hop on every call (Backend for Frontend).

How to build it

Most important first.

  • Decide from the threat you actually have and the topology you actually have, in that order. If the API is same-site with the app, a session cookie with HttpOnly, Secure and a considered SameSite is the least code and the smallest exfiltration surface (Sessions in Security Engineering).
  • If credentials must be sent cross-site, or a native client shares the backend, an explicit token is usually the honest answer — and then the durable half belongs behind an HttpOnly refresh cookie with the access token in memory only.
  • Whatever you choose, set the attributes deliberately and write down why. Secure always. HttpOnly unless script genuinely must read it — and "the framework reads it" is worth challenging. Host-only rather than Domain-wide unless a subdomain truly needs it.
  • If anything is sent automatically, add a CSRF defence for it rather than relying on SameSite alone: SameSite is a browser behaviour with different defaults across browsers and a moving specification, so it is a strong layer and a weak sole control (Cross-Site Request Forgery, and CSRF Defense in Security Engineering).
  • Prefer the shortest credential lifetime the product can tolerate, and make revocation a server capability rather than a client one. Deleting a value in the browser is a UI action, not an invalidation (Short-Lived Credentials in Security Engineering).
  • Reduce the blast radius on the assumption that script injection will eventually happen: a strict Content-Security-Policy, no innerHTML on untrusted data, and a hard look at every third-party script that runs in your origin (Content Security Policy, Third-Party Scripts and the Supply Chain).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • This decision has no direct interface, but its failure modes do: every one of them ends as a sudden logout, a re-auth prompt, or an error, and each must be announced rather than only rendered (Live Regions and Announcement).
  • If credentials are lost on reload by design, users of assistive technology are among the most affected — screen-reader and voice-control users reload more often when a page misbehaves, and each reload costs them their session.
  • Consent and cookie-preference interfaces are frequently the least accessible surface on a site while being legally required. If you own one, it needs real focus management and a keyboard-dismissible dialog like any other (Accessible Component Patterns).
  • Never make session recovery depend on a timed interaction. A "click to stay signed in" that expires in a few seconds is unusable for anyone navigating slowly (Contrast, Colour and Motion).

What can go wrong

Failure modes
  • SameSite=None set to make an integration work, without Secure, so the cookie is rejected outright — or with Secure and no CSRF token, so it is attached to every forged cross-site request.
  • A Domain=.example.com cookie shared with a subdomain that hosts user content or a third-party tool. Every one of those subdomains can now read the session (Storage Security and Durability).
  • An in-memory token with no refresh path: the user reloads, everything 401s, and the app treats it as a logout. The design was right and the missing half was the refresh cookie.
  • A token in Web Storage plus a permissive CSP plus one compromised dependency. Nothing here is exotic; it is the standard exfiltration chain (Dependency Security in Security Engineering).
  • The mitigation failing: HttpOnly prevents exfiltration but not use. Injected script cannot read the cookie and can still issue authenticated requests from your origin all day, which is why HttpOnly is a containment measure and not an XSS fix.
What can arrive out of order
  • A refresh writing a new credential while several requests are reading the old one. With storage, one tab can overwrite another's value mid-flight; with memory, each tab has its own and they expire independently (Auth Across Tabs).
  • An in-memory token and a reload: any request in flight when the page unloads is gone, and the request the user retries is the first one after boot, when the credential does not exist yet.
Security
  • The browser enforces cookie attributes absolutely: HttpOnly really does hide the value from script, and Secure really does withhold it from insecure requests. These are among the few client-side guarantees you can lean on (The Browser Security Model).
  • The browser does not enforce that a credential is used by the right page. Anything in your origin — your code, an injected script, a third-party tag — acts with the same authority (The Same-Origin Policy).
  • The two designs fail differently, which is the entire point: HttpOnly cookies remove exfiltration and keep CSRF; script-readable tokens remove CSRF and keep exfiltration. Neither dominates; the right answer depends on which risk your topology hands you.
  • A stolen credential is usable from anywhere until the server stops honouring it. That is why revocation and short lifetimes matter more than the storage choice does (Replay Attacks in Security Engineering).
  • Never place a credential in a URL. It lands in history, in referrers, in server logs, in shared links and in screenshots (The URL Is Application State).
Misreads
  • "Cookies are insecure" or "localStorage is insecure". Both are storage with different exposure; the useful question is which attack you are trading against which, and what else you have in place.
  • "localStorage is a safe home for any token." Nothing in the browser is a safe home for a long-lived credential; the design question is how long it lives and what reaches it.
  • "HttpOnly stops XSS." It stops the credential being read. Injected script still runs with your origin's authority and can simply make the requests itself (Cross-Site Scripting).
  • "SameSite=Lax means CSRF is handled." It removes a large class of it and leaves top-level GET navigations, differs by browser default, and is a specification still in motion (Cross-Site Request Forgery).
  • "Bearer tokens are more modern." They are a different transport with a different exposure profile. Session cookies remain the right answer for a great many same-site applications (Session Authentication in Backend Engineering).

Measuring it, and what changes in the field

How you would see this
  • Application panel, Cookies: the attribute columns are the audit. Anything without Secure, anything with an unconsidered SameSite, anything Domain-wide is a finding (Storage Security and Durability).
  • Network panel: check whether the credential is on the request at all, and whether a preflight is being issued for every call. A repeated OPTIONS in the waterfall is a cost you can often remove (Reading a Network Waterfall).
  • Console: document.cookie in a live session. Everything printed is readable by any script in the page, which makes the HttpOnly question concrete rather than theoretical.
  • CSP violation reports tell you what is trying to execute or exfiltrate in your origin, which is the risk this decision is actually trading (Content Security Policy).
Slow device, slow network, large data, old tab
  • Cross-site contexts are the case where these diverge most: browsers restrict cross-site cookies differently and the restrictions are still changing, so a cookie design that works in one browser today may need the token design tomorrow.
  • In an embedded context — an iframe on a partner site, a web view inside a native app — cookie behaviour is at its least predictable and at its most browser-dependent.
  • On a shared or managed device, "survives a reload" and "survives until tomorrow" are also risks rather than only features. Session-scoped credentials exist for this case.
  • With several tabs open, a stored credential is shared and an in-memory one is not, which changes what logging out has to do (Auth Across Tabs).
What this costs
  • HttpOnly cookies cost you flexibility: the client cannot inspect the credential, cannot easily attach it to a cross-site API, and inherits CSRF as a permanent obligation on every state-changing endpoint.
  • Script-readable tokens cost you exfiltration resistance: they are readable by definition, so their safety rests entirely on never having script injection — a defence with a poor historical record.
  • In-memory-only costs a refresh mechanism, a reload story, and a race to deduplicate. It buys the smallest theft surface of the three (Session Expiry and the Refresh Race).
  • The refresh-cookie-plus-memory-token hybrid is the strongest common answer and also the most moving parts: two credentials, two lifetimes, a refresh endpoint and a CSRF defence on it.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe core trade-off is structural rather than implementation-defined: a credential the browser attaches automatically is exposed to cross-site request forgery, and a credential your code attaches is exposed to anything that can read where it lives. That holds in every browser and will keep holding.
  • SPEC-EVOLVINGCookie behaviour is actively changing: default SameSite treatment, cross-site cookie availability, storage partitioning and the lifetime browsers grant client-set cookies have all moved in recent years and are expected to keep moving. Treat any specific default as a fact to re-check rather than to encode, and never rely on a default you did not set explicitly.
  • BROWSER-SPECIFICBrowsers differ today in how aggressively they restrict cross-site and third-party cookies and in what they do inside embedded contexts — some partition by top-level site, some block outright, some prompt. A cross-site cookie design must be verified in each engine you support rather than in the one on your machine.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — cookie attributes and credential transport are exactly the kind of configuration that should be asserted in an automated test rather than reviewed by eye, because a single missing attribute is invisible in code review and obvious in an assertion.
OS & Networkingwhy-tlshttp-basics