Tech Deep Dive · Browser Storage + Live Demos

Browser Storage —
Cookies, localStorage & sessionStorage

The three places a browser can hold data — when to pick which, the security trade-offs, and three live playgrounds on this page.

← Back to all deep dives

Why Browser Storage Exists — Two Problems, Three Solutions

Browsers ship with three different places to keep data: cookies, localStorage, and sessionStorage. They exist because the web needed to solve two different problems — and the solutions arrived fifteen years apart.

🌐 Problem 1 — HTTP is stateless

Every HTTP request is independent. The server has no memory that you logged in five seconds ago. Cookies (Netscape, 1994) patched this: the server hands the browser a small piece of paper, and the browser shows that paper on every subsequent request to the same site.

Set-Cookie: sid=abc123; HttpOnly; Secure

📦 Problem 2 — JS apps need local memory

Modern apps need to remember things without bothering the server: theme, draft text, wizard state, cached data. The Web Storage API (HTML5, 2009) gave JS two key-value stores — localStorage (persistent) and sessionStorage (per-tab).

localStorage.setItem('theme', 'dark'); sessionStorage.setItem('step', '2');

The flow is fundamentally different. Cookies travel with every HTTP request the browser makes — they're a server concern. Web Storage stays inside the browser, only visible to JS — pure client concern. We'll see exactly how cookies move between browser and server in Topic 4.

The Three Mechanisms — Side by Side

Before we dive into each, here's the whole landscape on one page. Every property that matters when you're picking which storage to use.

Property Cookies localStorage sessionStorage
Sent on every request YES NO NO
Capacity ~4 KB / cookie ~5–10 MB / origin ~5–10 MB / tab
Hidden from JS With HttpOnly NO NO
Lifetime Configurable (Expires / session) Until cleared Until tab closes
Cross-tab sharing Yes (same origin) Yes (same origin) No — tab scoped
API document.cookie + Set-Cookie header localStorage.setItem(k, v) sessionStorage.setItem(k, v)
Best for Auth sessions, CSRF tokens, server-rendered prefs Theme, draft text, app caches Single-flow wizards, OAuth state

Rule of thumb

If the server needs to read it on every request → cookie. If only the JS app needs it → localStorage (persistent) or sessionStorage (one-tab). Never store JWTs in localStorage unless your XSS posture is zero — and assume it isn't.

Cookies — Set-Cookie Anatomy & Every Attribute

A Set-Cookie header is one name=value pair followed by semicolon-separated attributes. Each attribute changes how the cookie is stored, sent, or restricted. To set multiple cookies, the server sends multiple Set-Cookie headers — never comma-separated.

Set-Cookie: sid=abc123XYZ; Domain=.example.com; Path=/; Expires=Wed, 09 Jun 2027 10:18:14 GMT; Max-Age=3600; Secure; HttpOnly; SameSite=Lax; Priority=High

Size limits

Per RFC 6265, browsers must support at least 4096 bytes per cookie and 50 cookies per domain. Modern browsers usually allow ~180 cookies per domain. The total Cookie header is capped at ~8 KB by most servers — blow that and you get HTTP 431.

📋 Every attribute, explained

The browser uses these to decide where a cookie is sent, how long it lives, and who can read it.

Attribute What it does Default
Domain Which host(s) receive the cookie. example.com sends to example.com + all subdomains. Omitting it locks the cookie to the exact origin host (no subdomains). exact host
Path URL path prefix the cookie is sent on. Path=/api means it ships only on /api and below. Use / for app-wide cookies. current path
Expires Absolute UTC date string. After this moment the browser discards the cookie. Omit both Expires and Max-Age and you get a session cookie (cleared when the browser closes). session
Max-Age Lifetime in seconds, relative to the server's response. Wins over Expires when both are present. Set Max-Age=0 to delete a cookie.
Secure Cookie is only sent over HTTPS. Without it, an attacker on the same Wi-Fi can sniff your session ID. Always set this in production. off
HttpOnly Hides the cookie from JavaScript (document.cookie can't see it). Mandatory for session cookies — prevents XSS from stealing the auth token. off
SameSite Strict = never sent cross-site. Lax = sent on top-level GET navigations only (the modern default). None = sent everywhere, but requires Secure. Lax
Priority Chrome-only hint (Low / Medium / High) for which cookies to evict first when the per-domain limit is hit. Medium
Partitioned CHIPS — opt into per-top-site partitioned storage for third-party cookies. Replaces unrestricted cross-site tracking. off
__Host- prefix Magic name prefix that forces Secure, Path=/, and no Domain. Strongest origin-locked cookie.

SameSite=None without Secure → silently dropped

Since Chrome 80 (Feb 2020), any cookie marked SameSite=None must also be Secure or the browser refuses to store it. If you're embedding into a third-party iframe and your session breaks in production, this is almost always why.

Cookies in Flight — A Login Flow Walkthrough

The anatomy section showed what a cookie looks like. This one shows how one actually travels between browser and server during a real login → authenticated-request cycle.

🌐 Browser
🖥️ Server
POST /login  {user, pass}
validate credentials · create session
Set-Cookie: sid=abc123; HttpOnly; Secure
browser stores cookie in jar
GET /dashboard  ·  Cookie: sid=abc123
look up session
200 OK

🍪 The cookie flow, arrow by arrow

One card per arrow in the diagram above. Read them in order ① → ④ to follow the cookie's full life-cycle from the login POST to the next auto-authenticated request.

Browser → Server

POST /login  {user, pass}

Plain HTTP POST. No session exists yet — just credentials in the body. The browser sends nothing special; the server is about to do the magic.

POST /login HTTP/1.1 Host: example.com Content-Type: application/json { "user": "alice", "pass": "..." }

After receiving this, the server validates the password and creates a session record server-side (Redis / DB). The browser only gets a tiny opaque session ID back — never the real state.

// server-side const sid = crypto.randomUUID(); await redis.set(`session:${sid}`, { userId, createdAt }, 'EX', 28800);
Server → Browser

Set-Cookie: sid=abc123; HttpOnly; Secure

What makes this a cookie is the response header, not the body. The browser parses Set-Cookie and writes an entry into its cookie jar, indexed by Domain + Path + Name.

HTTP/1.1 200 OK Set-Cookie: sid=abc123xyz; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=28800

Each attribute changes how it's stored:

  • HttpOnly — hidden from document.cookie, JS can't read it (defeats XSS exfiltration)
  • Secure — only attached on https://
  • SameSite=Lax — won't ship on cross-site POSTs (CSRF defense)
  • Max-Age=28800 — expires in 8 h; without it, dies on browser close
Browser → Server (later request)

GET /dashboard  ·  Cookie: sid=abc123

Frontend code does nothing. The browser auto-attaches the Cookie header on every subsequent request to the same site, as long as Domain / Path / Secure / SameSite all match.

GET /dashboard HTTP/1.1 Host: example.com Cookie: sid=abc123xyz

This is the entire reason cookies exist. Compare with localStorage — there you'd have to manually attach a token to every fetch(), and any XSS payload would steal it.

Server → Browser

200 OK  (session valid)

Server pulls the cookie, looks up the session record, recovers userId, and serves the response. Opaque ID → server-side record → user identity.

const sid = req.cookies.sid; const session = await redis.get( `session:${sid}`); if (!session) return res.status(401).send(); req.user = session.userId; // ... handle /dashboard for req.user

If the session expired or was revoked server-side (logout, password change, admin kick), the lookup returns nothing and the user gets 401 — even though they still have the cookie. The server is always the source of truth.

Three properties that make this work

  • HttpOnly defeats XSS exfiltration. JS can cause the cookie to be sent (via fetch(..., { credentials: 'include' })) but cannot read its value. A malicious script on your page can't steal the session ID and replay it elsewhere.
  • The cookie value is opaque. No user data, no JWT, no email — just a random ID. Putting data in the cookie itself ships it on every request and makes tampering a real problem.
  • The response writes the cookie, not the request. Frontend never calls document.cookie = ... for auth. The server header is the source of truth.

The cross-subdomain trap

Frontend on app.example.com, API on api.example.com? You must do all three:

  1. Set the cookie's Domain=.example.com so both subdomains see it.
  2. Frontend uses fetch(url, { credentials: 'include' }).
  3. Server responds with Access-Control-Allow-Credentials: true and an explicit Access-Control-Allow-Origin (not *).

Miss any one — browser silently drops the cookie. This is the classic "why isn't auth working in production" first-day bug.

Cookie Playground — Set, Inspect, Delete

This demo runs entirely on this page. Set a cookie below, watch the equivalent Set-Cookie header generated, see it appear in the cookie jar on the right, then inspect what document.cookie exposes. HttpOnly cookies cannot be created from JS by design — that flag only works when the server sets the cookie. The form lets you tick it so you can see the resulting header, but the browser won't actually store an HttpOnly cookie set this way.

cookies-playground · live on this page

Build a Set-Cookie

Equivalent header

Set-Cookie: demo_pref=dark-mode; Path=/; Max-Age=3600; SameSite=Lax

This is what a server would send. The button on the left writes it client-side via document.cookie.

Cookie Jar (this document)

Raw document.cookie

Notice anything missing? Cookies set with HttpOnly by your real server won't appear here. That's the entire point of the flag — JS-borne XSS can't read them.

Try this

  1. Set demo_pref=dark-mode with default flags.
  2. Refresh this page — the cookie persists for an hour.
  3. Open DevTools → Application → Cookies and you'll see it there too.
  4. Set the same name with Max-Age=0 to delete it.

Web Storage Playgrounds — localStorage & sessionStorage

The Web Storage API gives you a tiny surface — setItem(k, v), getItem(k), removeItem(k), clear(), plus a storage event that fires in other tabs when you change something. Both demos below speak that exact API; only the lifetime differs.

Set values, refresh the page, open a new tab — and watch how each store behaves. Both demos prefix keys with demo_ so they don't collide with anything else this site stores.

💾 localStorage — survives refreshes, restarts, and reboots

localStorage · same origin · ~5 MB · persists forever

Set / update an item

Equivalent JS

localStorage.setItem('demo_theme', 'dark');

Open a new tab on this same site after setting a key, then click "Refresh view" in that tab — same data shows up. That's same-origin sharing.

Stored items 0 keys · 0 B

Raw JSON.stringify(localStorage)

// empty

Try this — persistence proof

  1. Set theme=dark.
  2. Hit Cmd+R — still there.
  3. Quit your browser entirely.
  4. Reopen and come back to this page. Still there.

That's the whole point of localStorage.

🪟 sessionStorage — dies when this tab closes

sessionStorage · per-tab · ~5 MB · cleared on tab close

Set / update an item

Equivalent JS

sessionStorage.setItem('demo_step', '2');

Click the button below to open this same page in a brand-new tab. The new tab will show an empty jar — that's per-tab scoping.

Stored items 0 keys · 0 B

Raw JSON.stringify(sessionStorage)

// empty

Try this — per-tab scoping proof

  1. Click "Fill with checkout state" — a multi-step checkout object appears.
  2. Click "Open this page in new tab" — the jar in the new tab is empty.
  3. Refresh this tab — your data is still here.
  4. Close this tab and reopen the URL — gone.

That's the contract.

Real-World Use Cases — When To Pick Which

The comparison table tells you what; this section tells you when. Two strong examples per storage, with the code you'd actually ship — plus the anti-pattern that gets people fired.

The 10-second decision tree

Server needs it on every request?  →  Cookie
Only JS, must survive browser restart?  →  localStorage
Only JS, must die when tab closes?  →  sessionStorage

🍪 Cookies — when the server needs to know

🔐 Login session

The server creates a session row, hands the browser an opaque ID. Every subsequent request auto-attaches it; middleware looks up "who is this?" — no frontend code needed.

// Server (Express) res.cookie('sid', sessionId, { httpOnly: true, // XSS can't read it secure: true, // HTTPS only sameSite: 'lax', // CSRF defense maxAge: 8 * 60 * 60 * 1000 });

Why not localStorage? You'd have to attach a token manually to every fetch, and any XSS payload steals it instantly. HttpOnly is the only setup where JS can send credentials without being able to read them.

🧪 A/B test bucket at the edge

You want server-rendered HTML to come back with the right variant baked in — no flicker, no client-side hydration swap.

// Edge worker / CDN const bucket = req.cookies.ab_home ?? (Math.random() < 0.5 ? 'A' : 'B'); res.cookie('ab_home', bucket, { maxAge: 30 * 24 * 3600 * 1000 }); // render template based on `bucket`

The cookie ships with every request, so the same user sees the same variant on a hard refresh, an SSR cache hit, or an offline-then-reconnected reload.

Cookie anti-pattern

Storing a 4 KB JWT with the user's full profile in a cookie. It ships on every request — images, fonts, API calls, analytics beacons. That's wasted bandwidth on every page load. Keep cookies tiny (an opaque session ID); put the profile in localStorage or fetch on demand.

💾 localStorage — only the browser cares, forever

🌗 Theme preference

User toggles dark mode at 11 pm. Closes laptop. Comes back Monday morning. Site should still be dark — without a server round-trip just to know that.

// On toggle localStorage.setItem('theme', 'dark'); document.documentElement.dataset.theme = 'dark'; // On page load (sync, before paint) const t = localStorage.getItem('theme') || 'light'; document.documentElement.dataset.theme = t;

Persists across tabs, restarts, machine reboots. Server doesn't care, doesn't need to.

📝 Draft text auto-save

User is writing a long comment. Browser crashes. Without auto-save, work is gone. With four lines of code, it isn't.

// Restore on load editor.value = localStorage.getItem('draft') || ''; // Save while typing (debounced) let t; editor.addEventListener('input', () => { clearTimeout(t); t = setTimeout(() => { localStorage.setItem('draft', editor.value); }, 300); }); // Clear on successful submit form.addEventListener('submit', () => localStorage.removeItem('draft'));

5–10 MB ceiling means you can hold a lot of text. The storage event lets two open tabs sync as the user types — feature or bug, depending on UX.

localStorage anti-pattern

Storing a JWT or session token here. Any XSS = token exfiltrated, instantly, with no audit trail. The industry burned itself on this in 2017–2019; there's a reason every modern auth library now defaults to HttpOnly cookies for the access path.

🪟 sessionStorage — state that should die with the tab

🧭 Multi-step wizard / checkout

User is 4 steps into checkout. They open a product detail in a new tab. That new tab should not see step-4 state — it's a different shopping intent.

// Step 1 sessionStorage.setItem('checkout', JSON.stringify({ cart, shipping })); // Step 4 const s = JSON.parse( sessionStorage.getItem('checkout')); s.payment = paymentDetails; sessionStorage.setItem('checkout', JSON.stringify(s)); // Final submit clears sessionStorage.removeItem('checkout');

If you used localStorage, two open tabs would step on each other's checkout. If you used a cookie, you'd ship 2 KB of cart state with every API call including unrelated ones.

🔁 OAuth state & PKCE verifier

You redirect to Google. Google redirects back to /callback?state=xyz&code=.... You need to verify state matches what you sent — but only in this tab.

// Before redirect const state = crypto.randomUUID(); const verifier = crypto.randomUUID(); sessionStorage.setItem('oauth_state', state); sessionStorage.setItem('pkce_verifier', verifier); location.href = `https://accounts.google.com/... &state=${state}&code_challenge=...`; // On /callback const expected = sessionStorage.getItem('oauth_state'); const got = new URL(location.href) .searchParams.get('state'); if (expected !== got) throw new Error('CSRF: state mismatch');

Per-tab scoping kills the attack where a victim opens two simultaneous OAuth flows and an attacker tricks the callback into validating against the wrong one.

sessionStorage anti-pattern

Putting "user is logged in" state in sessionStorage. Closing the tab should not log you out — that's surprising UX, and it's the wrong tool. For "remember me", use a cookie. For "log out when I close the browser", use a session cookie (no Max-Age) — not sessionStorage.

📋 Decide by question

Ask yourself Pick
Do I need this on the server? Yes → cookie
Should it survive closing the tab? Yes → localStorage  ·  No → sessionStorage
Is it sensitive (auth token, PII)? Cookie + HttpOnly. Never localStorage.
Do two tabs need to share it? Yes → localStorage  ·  No → sessionStorage
Is each new tab a fresh attempt? (wizard, OAuth, draft you don't want re-loaded) sessionStorage
Will it be > 4 KB? Not a cookie. localStorage or IndexedDB.
Will it be > 5 MB? Not Web Storage. Use IndexedDB.

Security Pitfalls — Across All Three Stores

The threat model differs per storage. Cookies face CSRF; localStorage faces XSS exfiltration; sessionStorage faces tab-takeover variants. Below: the four most common ways each one bites teams in production.

🪤 XSS → Cookie theft

An attacker injects <script>fetch('/x?c='+document.cookie)</script>. If your session cookie is missing HttpOnly, the attacker now owns the session. Mitigation: HttpOnly on every auth cookie + CSP + escape on output.

🎯 CSRF → forged requests

The browser auto-attaches cookies, so a malicious site can submit a form to your bank with the user's session attached. Mitigation: SameSite=Lax (now default) blocks cross-site POSTs, plus a CSRF token bound to the session.

🔓 MITM → cookie sniffing

Without Secure, the cookie ships over plain HTTP — any router on the path reads it. Mitigation: HSTS + Secure + __Host- prefix on session cookies.

♻️ Session fixation

Attacker tricks the user into authenticating with a session ID the attacker already knows. Mitigation: rotate the session ID on login (issue a new Set-Cookie the moment auth succeeds).

Production session cookie checklist

  • HttpOnly
  • Secure
  • SameSite=Lax (or Strict for high-value apps)
  • __Host- prefix
  • ✓ Short Max-Age with sliding expiry
  • ✓ Rotate on login & on privilege change
  • ✓ Server-side revocation (sessions are real records, not just signed JWTs)

The End of Third-Party Cookies (and What's Replacing Them)

For two decades, ad networks tracked users across sites by relying on cookies set by an iframe to doubleclick.net being readable on every other site that embedded a DoubleClick pixel. Browsers have been progressively shutting this down.

Safari ITP (2017)

Apple's Intelligent Tracking Prevention started capping third-party cookie lifetimes — first to 30 days, then 24 hours, then full block by 2020.

Firefox ETP (2019)

Enhanced Tracking Protection blocks third-party cookies from a known-tracker list by default, then expanded to total cookie isolation per top-level site.

Chrome SameSite=Lax default (2020)

Cookies without an explicit SameSite attribute are treated as Lax. This alone broke a lot of cross-site flows that quietly relied on the old default.

CHIPS — Partitioned cookies (2024+)

The Partitioned attribute lets a third-party cookie still be set, but it's keyed by (top-level site, third-party origin) — so an embedded chat widget gets one bucket on siteA.com and a totally separate bucket on siteB.com. Tracking dies; legit embeds still work.

Privacy Sandbox / Topics API

Chrome's replacement for ad targeting: the browser categorizes the user locally into a few interest topics and exposes only those — no cross-site identifier.

Interview Q&A

Difference between session and persistent cookies?
A session cookie has no Expires or Max-Age — the browser drops it when the window closes (modulo "restore tabs"). A persistent cookie has an explicit lifetime and survives restarts. Server-side "session" usually uses a persistent cookie holding a session ID, separate from the HTTP-cookie meaning of "session".
Can JavaScript read an HttpOnly cookie?
No — that's the whole point. document.cookie filters out HttpOnly entries. The cookie still rides on every fetch the browser makes, just not in the JS API. This is the single biggest XSS mitigation for session tokens.
How do you delete a cookie?
You can't directly delete it — you set the same name, Domain, and Path with Max-Age=0 (or an Expires date in the past). The browser overwrites the existing entry and immediately discards it. Mismatched Domain or Path = a new cookie alongside the old one.
SameSite=Lax vs Strict — when to pick which?
Lax sends the cookie on top-level navigation GETs (clicking a link to your site keeps you logged in) but blocks cross-site POST/PUT/DELETE. Strict never sends cross-site, period — even if the user clicks a link from email, they arrive logged out. Pick Strict for banking-grade flows; Lax is the right default everywhere else.
What's a first-party vs third-party cookie?
First-party = the cookie's domain matches the site in the address bar. Third-party = the cookie's domain belongs to an embedded resource (iframe, image, script) from a different origin. Browsers are aggressively killing third-party cookies; CHIPS is the migration path for legit cross-site embeds.
Why is the __Host- prefix considered the strongest cookie config?
It enforces three rules at the browser level: cookie must be Secure, must have Path=/, and must not have a Domain attribute. That guarantees the cookie is bound to one exact origin and can't be set by, or leak to, any subdomain. A subdomain takeover can no longer overwrite it.
Cookies vs JWT in localStorage — which is "more secure"?
Trick question — neither is universally better, the threat models differ. Cookie + HttpOnly + SameSite: safe from XSS theft, but you need CSRF defenses. JWT in localStorage: no CSRF risk (you must explicitly attach it), but any XSS = full token exfiltration. The industry trend: HttpOnly cookies for the access path + opaque server-side sessions for revocation.
What happens if I set a cookie with Domain=.google.com from evil.com?
The browser rejects it. Cookies can only be set for the request's origin or one of its parent domains that isn't a public suffix. evil.com can set .evil.com but never .google.com. The Public Suffix List prevents .com or .co.uk from being set as a domain.
A cookie disappears in production but works locally — what's the most likely cause?
In order of likelihood: (1) Missing Secure + the prod site is HTTPS but a redirect path drops it, (2) SameSite=None without Secure → silently dropped by Chrome, (3) Domain mismatch (set on www.x.com, request goes to x.com), (4) a CDN strips Set-Cookie on cacheable responses, (5) Path scoping too narrow.
How big is the cookie header allowed to be?
Per cookie: 4 KB minimum support, ~4096 bytes is the practical cap. Per request Cookie header: most servers cap at 8 KB total — exceed it and you get HTTP 431 Request Header Fields Too Large or a silent connection drop. Big tokens belong in Authorization (or in opaque server sessions), not in cookies.
localStorage is synchronous — does that actually matter?
It does. Every getItem/setItem blocks the main thread. For a few KB it's invisible, but a 4 MB JSON blob being parsed on every page load is a measurable jank source. If you're storing structured app state > 100 KB, switch to IndexedDB (async) — same persistence, no main-thread cost.
If I open the same site in two tabs, do they share sessionStorage?
No — that's the entire point of sessionStorage. Each top-level browsing context gets its own. The exception: a tab opened via window.open (or a target=_blank link without noopener) inherits a copy of the opener's sessionStorage at creation time, then diverges. localStorage always shares across tabs of the same origin.
What's the storage event and when does it fire?
It fires in other tabs/windows of the same origin when localStorage changes. Useful for cross-tab sync — log a user out in one tab, all other open tabs notice. Caveat: it does not fire in the tab that made the change, and does not fire for sessionStorage. Try it: open this page in two tabs and change a demo_* key — the other tab will toast.
When should I reach for IndexedDB instead of localStorage?
Three triggers: (1) data > 5 MB, (2) structured queries (you want indexes, ranges), (3) the synchronous parse cost of localStorage is showing up in your performance budget. IndexedDB is async, transactional, and pretty much unlimited (within disk quota). Libraries like idb-keyval give you a localStorage-shaped API on top of IndexedDB if you don't need the full power.
Are cookies, localStorage, and sessionStorage origin-scoped or domain-scoped?
localStorage and sessionStorage are origin-scoped — scheme + host + port all must match. http://x.com and https://x.com get separate buckets. Cookies are domain-scoped by default and don't differentiate between schemes (a cookie set over HTTPS is sent over HTTP unless Secure is set). This mismatch is a recurring source of bugs.
Cookie set

Did this clear up cookies vs localStorage vs sessionStorage for you? If it clicked, tap the ❤️ — that's how I know it hit.