# Cookies vs localStorage vs sessionStorage: What to Use

> Cookies vs localStorage vs sessionStorage: request behavior, size limits, expiry, tab scope, partitioning, XSS and CSRF risks, and auth token choices.

Source: https://howhttpworks.com/compare/cookies-vs-localstorage
Last reviewed: 2026-10-05

> **TL;DR:** Use cookies for anything the server needs on every request, localStorage for preferences that should survive a restart, and sessionStorage for per-tab state. For logins, an HttpOnly session cookie plus CSRF protection is the safe default. Any script on your origin can read localStorage and sessionStorage, so neither one protects a token from XSS.

## Side By Side

| Property | Cookies | localStorage | sessionStorage |
|---|---|---|---|
| Sent on HTTP requests | Automatically when cookie scope, credentials mode, and browser policy allow | No; application code must send values | No; application code must send values |
| JavaScript access | Allowed unless `HttpOnly` | Read/write | Read/write |
| Size | 4096-octet name-plus-value check in RFC 6265bis; see below | MDN documents up to 5 MiB per origin | MDN documents up to 5 MiB per origin |
| Expiry | `Max-Age` / `Expires`, or a browser-defined session | No built-in expiry | Ends with the page session; survives reloads |
| Sharing across tabs | Matching cookies are available to requests from multiple tabs in the same cookie partition | Shared for the same origin and storage partition | Separate per origin and top-level tab |
| Scope | Host/domain and path rules; partitioning may add a top-level-site key | Origin, with possible extra browser partitioning | Origin and tab, with possible extra browser partitioning |

The request behavior comes from [MDN's cookie guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies) and [Fetch credentials modes](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials); the lifetime and tab rules from MDN's [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) and [sessionStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) pages. Privacy settings can block or clear any of them, so build for storage being unavailable.

## When To Use Each

**Cookie: a server-backed session.** The server creates a session ID and sets it in a cookie. This example response (trimmed, like the others here) gives it a 30-minute lifetime:

```http
HTTP/1.1 200 OK
Content-Length: 0
Set-Cookie: __Host-session=example-session-id; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800
```

The `__Host-` prefix makes supporting browsers insist on `Secure`, `Path=/`, and no `Domain` attribute. `HttpOnly` hides the cookie from scripts, but the browser still sends it on matching requests. See [MDN's attribute and prefix rules](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie). Expire the session on the server too. The cookie's `Max-Age` controls when the browser drops it, but only the server can actually end a session.

**localStorage: a browser-only preference.** Say, the user's preferred table density, remembered between visits. **sessionStorage: a per-tab draft.** Say, an unsent filter expression that shouldn't leak into another tab:

```javascript
try {
  localStorage.setItem('table-density', 'compact');
  sessionStorage.setItem('draft-filter', 'status:open');
} catch (error) {
  // Continue with in-memory UI state when persistence is unavailable.
  console.warn('Browser storage unavailable:', error.name);
}
```

[WHATWG defines both as string key/value stores](https://html.spec.whatwg.org/multipage/webstorage.html). `setItem()` throws `QuotaExceededError` when storage is full, and just touching `localStorage` can throw `SecurityError` when a policy blocks it ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)). Wrap access in `try`, and make sure the table still renders without it.

## Size Limits: Bytes Are Not Characters

As of 2026-10-05, **RFC 6265bis is still a draft in the RFC Editor's final review**, not a published RFC; see its [Datatracker status](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/). Its [§5.6 parsing algorithm](https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#section-5.6) rejects a cookie whose **name plus value exceeds 4096 bytes**. The `=` and the attributes don't count toward that. It's a rule about name and value, not a cap on the whole `Set-Cookie` line, and browsers vary near the edge. Stay well under it and check in DevTools that the browser kept the cookie.

For Web Storage, [MDN documents 5 MiB of localStorage and 5 MiB of sessionStorage per origin](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria#web_storage). That's for the whole origin, not per key, and it's a fixed cap rather than a share of disk like IndexedDB gets. The WHATWG spec doesn't fix a number; its [storage algorithm](https://html.spec.whatwg.org/multipage/webstorage.html#dom-storage-setitem) just says `setItem()` fails when the value can't be stored. So catch that failure instead of trying to predict capacity from string lengths.

## Auth Tokens: XSS And CSRF Are Separate Problems

If your backend can own the session, an **HttpOnly, Secure cookie** is a good default, because the frontend never needs to read the credential. [OWASP's HTML5 cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html#local-storage) specifically advises against keeping session identifiers in localStorage, since JavaScript can read them. sessionStorage has the same problem. Its shorter lifetime narrows the window, but a script reads it just as easily.

HttpOnly stops a script from stealing the cookie. It doesn't stop an injected script from acting as the user, because the browser still attaches the cookie to the requests that script makes. Cookie authentication also needs CSRF protection, since the browser attaches the cookie to requests an attacker's page triggers too. Use your framework's CSRF mechanism on state-changing requests and pick a `SameSite` value on purpose. [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) treats SameSite as one layer with known gaps, such as sibling hosts on the same site, and points out that XSS can get around CSRF defenses.

If your frontend sends a bearer token straight to an API, your script has to be able to read it, and an HttpOnly cookie can't fill in an `Authorization` header. Two common options: keep the access token in memory and fetch a new one after a reload, or put a backend in front that holds the API credential and gives the browser a cookie session. If you really need to persist the token, write down why, and keep its lifetime and permissions small. Memory just limits how long the token sticks around; malicious code running in your app can read it all the same.

Sending a token yourself sidesteps classic CSRF, which relies on the browser attaching cookies automatically, **as long as the endpoint accepts only that token**. If it also accepts a cookie, CSRF is back on the table, and XSS was never off it. Judge the risk by how the server actually authenticates the request, not by which storage API holds the token.

## Expiry, Tabs, And Partitioning

localStorage entries never expire on their own. A JWT's [expiry claim](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4) decides whether a server accepts the token; the string itself sits in storage until something deletes it. sessionStorage survives reloads and lasts until the page session ends. A tab opened from another page can start with a **copy** of the opener's sessionStorage, and from then on the two are independent ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage)). Cookies with no `Expires` or `Max-Age` are session cookies, but a browser's session restore can bring them back ([MDN Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#expiresdate)). So "close the browser to log out" isn't a way to end a session; revoke it on the server.

An embedded widget may see different storage depending on which site embeds it. [MDN's state-partitioning guide](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/State_Partitioning) describes Firefox splitting third-party Web Storage by top-level site. For cookies, [CHIPS](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Third-party_cookies/Partitioned_cookies) adds a `Partitioned` attribute (which requires `Secure`) that gives each top-level site its own cookie jar. Browsers differ here, so test the widget embedded on two different sites, not just by visiting it directly.

## The Common Mistake

Moving a login token to sessionStorage and calling it XSS protection. Both Web Storage APIs hand their values to any script on the origin. The other version of this mistake is moving the token into a cookie without HttpOnly. Scripts can still read it, and now the browser sends it automatically too, which opens you to CSRF. Check the actual cookie flags and how the server authenticates before you call the change an improvement.

## Check On The Wire And In DevTools

In Chrome, open **Application → Storage → Cookies**, **Local Storage**, and **Session Storage** for your origin. For each cookie, check `HttpOnly`, `Secure`, `SameSite`, expiry, and partition key. Then switch to Network and look at a request's **Cookie** header, because a cookie being stored doesn't mean a given request sent it. Chrome documents the [cookie](https://developer.chrome.com/docs/devtools/application/cookies), [localStorage](https://developer.chrome.com/docs/devtools/storage/localstorage), and [sessionStorage](https://developer.chrome.com/docs/devtools/storage/sessionstorage) panels.

To check what the server sends, point this at your staging route:

```bash
curl -sS -D - -o /dev/null https://example.com/session
```

Look for `Set-Cookie` and its attributes. curl shows what the server sent, not what a browser did with it: acceptance, SameSite enforcement, partitioning and localStorage all need DevTools. And if an HttpOnly cookie is missing from `document.cookie`, that's the flag working. Check the request header and the cookie panel instead.

## Related

- [Set-Cookie](https://howhttpworks.com/headers/set-cookie) and [Cookie](https://howhttpworks.com/headers/cookie)
- [Cookie security](https://howhttpworks.com/guides/cookie-security) and [SameSite](https://howhttpworks.com/cookies/same-site)
- [Cookies vs sessions](https://howhttpworks.com/compare/cookie-vs-session)
