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.
Where should a credential live in the browser, and what does each choice give an attacker who gets a foothold?
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.
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.
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).
- 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
Securetravels in cleartext to any non-HTTPS URL on the host, and a cookie without a consideredSameSiteis attached to requests your site did not initiate (Cross-Site Request Forgery). - The
localStorageversion 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.
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,SecureandSameSiteattributes. Your code is not involved, which is simultaneously its best and worst property. HttpOnlyremoves the cookie fromdocument.cookieentirely. 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).Securerestricts the cookie to secure-context requests.SameSiterestricts it by the relationship between the site making the request and the site the cookie belongs to:Strictwithholds it on any cross-site request including top-level navigation,Laxallows it on top-level GET navigations,Nonesends it always and is only accepted together withSecure.Domainwidens the cookie to subdomains — omit it and the cookie stays host-only, which is almost always what you want.Pathnarrows 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-
HttpOnlycookie, 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
HttpOnlyrefresh 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
Authorizationis 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 matchingAccess-Control-Allow-Credentialson 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
localStoragereads 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` cookie | Token in Web Storage | Token in memory only |
|---|---|---|---|
| Readable by injected script | No — the browser hides it from document.cookie | Yes — one line of script reads it | Yes, if the script can reach the module scope holding it |
| Sent automatically | Yes, by attribute matching | No — your code attaches it | No — your code attaches it |
| CSRF exposure | Yes; needs SameSite plus a token or origin check | None by default | None by default |
| Survives reload | Yes, until it expires | Yes, until cleared or evicted | No — this is the point of it |
| Shared with other tabs | Yes, one jar per profile | Yes, one store per origin | No — each document has its own |
| Works cross-site | Only with SameSite=None; Secure, and browsers increasingly restrict it | Yes, on an explicit header | Yes, on an explicit header |
| Cost per cross-origin request | None extra beyond CORS credentials setup | A preflight, because Authorization is not safelisted | A preflight, same reason |
| Revocation | Server-side; clearing the cookie is only cosmetic | Server-side; deleting the value is only cosmetic | Server-side; the value dies on unload regardless |
| Typical leak path | Overly wide Domain, missing Secure, a subdomain takeover | Injected script, a compromised dependency, a devtools-savvy extension | Injected script only, and only while the tab is open |
The attributes are the control surface
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` —
Strictfor pure session credentials where arriving from an external link may re-prompt;Laxfor most;Noneonly withSecureand 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.
1HTTP/1.1 200 OK2Set-Cookie: __Host-session=<opaque-id>;3 HttpOnly; # script cannot read it — exfiltration off the table4 Secure; # never sent over an insecure request5 SameSite=Lax; # withheld from cross-site subresource requests6 Path=/; # required by the __Host- prefix7 Max-Age=<seconds> # a lifetime the server chose and can shorten8# 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/cancel14Cookie: __Host-session=<opaque-id>15X-CSRF-Token: <token the page read from a same-origin source>16Origin: https://app.example.comTwo 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).
Given this application's topology and threat model, which credential design is right?
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).
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.
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).
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).
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,Secureand a consideredSameSiteis 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
HttpOnlyrefresh cookie with the access token in memory only. - Whatever you choose, set the attributes deliberately and write down why.
Securealways.HttpOnlyunless script genuinely must read it — and "the framework reads it" is worth challenging. Host-only rather thanDomain-wide unless a subdomain truly needs it. - If anything is sent automatically, add a CSRF defence for it rather than relying on
SameSitealone:SameSiteis 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
innerHTMLon 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
SameSite=Noneset to make an integration work, withoutSecure, so the cookie is rejected outright — or withSecureand no CSRF token, so it is attached to every forged cross-site request.- A
Domain=.example.comcookie 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:
HttpOnlyprevents exfiltration but not use. Injected script cannot read the cookie and can still issue authenticated requests from your origin all day, which is whyHttpOnlyis a containment measure and not an XSS fix.
- 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.
- The browser enforces cookie attributes absolutely:
HttpOnlyreally does hide the value from script, andSecurereally 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:
HttpOnlycookies 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).
- "Cookies are insecure" or "
localStorageis 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. - "
localStorageis 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. - "
HttpOnlystops 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=Laxmeans 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
- Application panel, Cookies: the attribute columns are the audit. Anything without
Secure, anything with an unconsideredSameSite, anythingDomain-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
OPTIONSin the waterfall is a cost you can often remove (Reading a Network Waterfall). - Console:
document.cookiein a live session. Everything printed is readable by any script in the page, which makes theHttpOnlyquestion 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).
- 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).
HttpOnlycookies 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
SameSitetreatment, 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.
- — 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.