HTTP is stateless: each request is handled on its own, and the server keeps no memory of the last one. Yet you log in once and stay logged in across pages, tabs and days, and a half-written draft survives a reload. Browsers give you several places to keep that state, and they differ in size, lifetime, scope, speed — and, most importantly, whether the server sees them and whether an injected script can steal them.
This guide starts with the one the server sees (cookies, and the sessions built on them), then covers the four the server doesn't.
What a cookie is
A cookie is a small piece of text a server asks the browser to store, and that the browser then sends back on every later request to the same site. The server sets it with a response header:
Set-Cookie: sid=s_8f2c91; HttpOnly; Secure; SameSite=Lax; Max-Age=1209600
And from then on, the browser adds it to requests automatically:
GET /account HTTP/1.1
Host: shop.example
Cookie: sid=s_8f2c91
Your JavaScript doesn't need to do anything. That automatic sending is what
makes cookies convenient — and, as we'll see, what makes them a security
topic. Scripts can also write cookies, as long as they aren't HttpOnly:
document.cookie = 'theme=dark; Max-Age=31536000; Path=/; Secure; SameSite=Lax';
Sessions: a key, not the data
The usual pattern for login is a session:
HTTP is stateless: each request stands alone, and the server doesn't remember you between them. Cookies are how the browser carries a little state back every time.
- You log in. The server checks your password.
- It creates a session record on the server — "session
s_8f2c91belongs to user 42" — in a database or a store like Redis. - It sends only the random session ID to the browser as a cookie.
- Every later request carries the ID; the server looks it up to find out who you are.
The cookie holds no personal data — just an unguessable key. Logging out deletes the record on the server, which instantly makes the key useless, wherever it might have been copied.
The attributes that matter
| Attribute | What it does | Protects against |
|---|---|---|
| HttpOnly | JavaScript can't read the cookie | Stealing sessions via injected scripts (XSS) |
| Secure | Only sent over HTTPS | Eavesdropping on unencrypted connections |
| SameSite=Lax | Not sent on most cross-site requests | Other sites triggering actions as you (CSRF) |
| Max-Age / Expires | When the browser deletes it | Sessions living forever |
| Domain / Path | Which URLs it's sent to | Leaking it to parts of the site that don't need it |
For a session cookie, HttpOnly; Secure; SameSite=Lax is the sensible
default. Browsers now treat cookies without a SameSite attribute as
Lax anyway.
CSRF in one paragraph: because cookies are sent automatically, a
malicious page could make your browser submit a form to your bank, and the
bank would see your valid cookie. SameSite stops the browser attaching
the cookie when the request starts on another site. For extra safety,
sensitive actions also check a CSRF token or the Origin header — see
CSRF explained.
Sessions vs tokens
The common alternative is a token such as a JWT: instead of a key to server-side data, the client holds a signed blob that contains the user's identity, and the server verifies the signature instead of looking anything up.
| Server session + cookie | Signed token (e.g. JWT) | |
|---|---|---|
| Where state lives | On the server | Inside the token |
| Lookup per request | Yes (fast, cacheable) | No — verify a signature |
| Log out / revoke | Delete the session — instant | Hard until it expires, unless you keep a deny list |
| Good fit | Websites, same-site apps | Service-to-service calls, APIs used by many clients |
For a normal website with a login, server sessions in an HttpOnly cookie
are simpler and safer. Tokens shine when many independent services need to
verify identity without sharing a session store. Note that a token can be
stored in a cookie too — the two ideas aren't exclusive.
Other things cookies are used for
- Preferences — theme, language, a dismissed banner.
- Shopping carts for visitors who haven't logged in.
- Third-party tracking — cookies set by a different domain embedded on many sites. Browsers increasingly block these by default, which is why you see fewer of them over time.
Cookies are limited to about 4 KB each, and every byte travels with every request. They're for keys and small server-relevant values, not data. For everything else, the browser has storage the server never sees.
Storage the server doesn't see
Cookies are the only storage the browser sends to your server automatically, on every request. That makes them right for session IDs — and wrong for anything big.
localStorage
localStorage.setItem('draft', JSON.stringify(draft));
const draft = JSON.parse(localStorage.getItem('draft') ?? 'null');
- Strings only — serialise objects with JSON.
- Around 5 MB per origin, kept until cleared.
- Shared across all tabs of the same site; other tabs get a
storageevent when it changes. - Synchronous: every read and write blocks the main thread. Fine for small values, bad for large ones.
Good for preferences, small drafts, "last viewed" state.
sessionStorage
Same API as localStorage, but scoped to one tab and cleared when the
tab closes. Each tab gets its own copy, so it's handy for per-tab state like
the step a user is on in a wizard.
IndexedDB
// with the small 'idb' wrapper library
const db = await openDB('app', 1, {
upgrade(db) { db.createObjectStore('notes', { keyPath: 'id' }); },
});
await db.put('notes', { id: 1, text: 'Hello', updated: Date.now() });
const note = await db.get('notes', 1);
- A real database: stores objects, arrays,
Blobs and files, with indexes and transactions. - Asynchronous, so large reads don't freeze the page — and available inside workers.
- Can hold a lot: browsers allow a substantial share of free disk space.
The raw API is verbose and event-based, so most people use a small promise wrapper. It's the right home for offline data, caches of API results, and anything big.
Cache API
const cache = await caches.open('v1');
await cache.put('/api/feed', response);
const cached = await cache.match('/api/feed');
Stores whole HTTP Request → Response pairs. It's designed for
service workers to serve pages and
assets offline, but pages can use it too.
Side by side
| Cookies | localStorage | sessionStorage | IndexedDB | Cache API | |
|---|---|---|---|---|---|
| Typical size | ~4 KB each | ~5 MB | ~5 MB | Large | Large |
| Lifetime | Until expiry | Until cleared | Tab closes | Until cleared | Until cleared |
| Sent to server | Every request | No | No | No | No |
| API | String parsing | Sync, strings | Sync, strings | Async, objects | Async, responses |
| Readable by injected scripts | Not if HttpOnly | Yes | Yes | Yes | Yes |
Security: where not to put secrets
Anything JavaScript can read, an XSS
attack can steal. That rules out putting long-lived authentication tokens in
localStorage, sessionStorage or IndexedDB: one injected script and
they're gone, usable from anywhere. An HttpOnly, Secure, SameSite
cookie can't be read by scripts at all, which is why it's the safer home
for session credentials.
For sessions themselves:
- Never put secrets or personal data directly in a cookie value; store a random ID and keep the data on the server.
- Generate session IDs with a cryptographically secure random generator, and issue a new one after login so an attacker can't plant a known ID beforehand.
- Expire sessions — both an idle timeout and an absolute maximum.
For browser storage:
- Storage is per origin, and visible to anyone using the same browser profile. Don't store other people's private data unencrypted.
- Browsers can evict non-cookie storage when disk space runs low,
especially for sites the user rarely visits. Call
navigator.storage.persist()to request durable storage for data you can't re-download. - Private browsing clears everything when the window closes, and some browsers clear script-written storage for sites that haven't been visited in a while. Treat browser storage as a cache, not the only copy.
- Every access can throw — storage disabled, quota exceeded, private
mode. Wrap it in
try/catchand keep working without it.
Choosing
- Who you are, checked by the server on each request → an
HttpOnlysession cookie. - Other small values the server needs on each request → cookie.
- Small preference or draft, shared across tabs → localStorage.
- Per-tab, temporary state → sessionStorage.
- Lots of structured data, files, offline data → IndexedDB.
- Offline copies of pages and HTTP responses → Cache API.
The short version: the server remembers, the cookie only reminds it which memory is yours — and everything else the page keeps for itself.