A login endpoint hands back a JWT, and you have three places to put it: a cookie, localStorage, or sessionStorage. Pick the wrong one and either an XSS bug walks off with the token, or the token never reaches your API. All three store strings in the browser. They differ on who can read them, how long they last, and whether the server ever sees them.
How localStorage, sessionStorage and cookies differ
localStorage and sessionStorage are the same API, the Storage interface: setItem, getItem, removeItem, clear, string keys and string values. The difference is lifetime. localStorage survives closing the browser and rebooting the machine, and stays until something clears it. Safari is the exception: with tracking prevention on, it deletes script-written storage for a site the user hasn’t interacted with in seven days. sessionStorage belongs to one tab and dies when that tab closes. Type the same URL into a second tab and you get a fresh, empty sessionStorage; only “Duplicate tab” copies it over.
Cookies are older and work differently. The server sets one with a Set-Cookie response header, and from then on the browser attaches it to every matching request in the Cookie header, automatically. That includes image and stylesheet requests and same-origin fetch calls you never configured to send anything. localStorage and sessionStorage never leave the browser unless your JavaScript reads them and sends them somewhere.
Sent automatically versus never sent: that one property settles most of the “which one” question.
Storage limits and persistence compared
| Cookies | localStorage | sessionStorage | |
|---|---|---|---|
| Sent to server | Yes, every matching request | No | No |
| Capacity | ~4KB per cookie | ~5-10MB per origin (browser-dependent) | ~5-10MB per origin |
| Persistence | Set by Expires/Max-Age; session cookie if omitted | Until explicitly cleared | Until the tab closes |
| Scope | Domain + path (configurable) | Origin (scheme + host + port) | Origin, and only the current tab |
| Readable from JS | Yes, unless HttpOnly | Always | Always |
| API | String header, or document.cookie | Storage (sync) | Storage (sync) |
The capacity gap is why carts and settings blobs don’t go in cookies. 4KB runs out fast once you store JSON, and every byte gets re-uploaded on every request whether the endpoint needs it or not. A page that loads 40 static assets from the same domain with a 3KB cookie attached sends that cookie 40 times.
How to read and write each one in JavaScript
localStorage and sessionStorage take the same calls:
| |
Both store only strings. setItem("user", {name: "Dan"}) stores the literal string "[object Object]". Serialize first:
| |
From JavaScript, a cookie goes through document.cookie:
| |
It looks like an assignment that overwrites everything. It isn’t. Each assignment sets or updates one cookie, and reading document.cookie back gives you every cookie for the page as a single ; -joined string. You split on ; and then on the first = yourself, or use a small library that does it.
Most cookies are set by the server instead, in the response headers. That is also the only way to get the protections that matter for anything sensitive:
| |
That call produces a response header like:
| |
HttpOnly, Secure and SameSite: what makes a cookie safe
HttpOnlyremoves the cookie fromdocument.cookieentirely. JavaScript on the page cannot read it, and that includes an attacker’s injected script. For a session token this is the main reason to use a cookie overlocalStorage: an XSS bug that runs arbitrary JS still can’t exfiltrate anHttpOnlycookie.Securestops the browser from sending the cookie over plain HTTP; it goes out only over HTTPS. Set it on everything in production. See what TLS does under HTTPS for what that protection is worth.SameSitecontrols whether the cookie goes out on cross-site requests.Strictsends it only on same-site requests.Laxstill sends it on top-level GET navigation, so following a link from an email still logs you in, but withholds it from cross-site POSTs andfetchcalls. That blocks most cross-site request forgery.Nonesends it everywhere and requiresSecure, or the browser drops the cookie outright. Chrome and Edge treat a cookie with noSameSiteasLax; Firefox and Safari don’t, so set it explicitly.
localStorage and sessionStorage have no equivalent of any of these. Whatever you put there is readable by every script that runs on your origin: your own code, a compromised npm dependency, an attacker’s payload after an XSS hole. Cookies aren’t safer by nature. They have an opt-in mode that JavaScript can’t touch, and localStorage doesn’t. That is the whole argument against keeping an auth token in localStorage.
Why cookies aren’t sent on cross-origin fetch requests
A page on app.example.com calls api.example.com with fetch. The cookie set by api.example.com stays home, even with the right domain on the cookie, because fetch only sends credentials to the same origin by default. You have to opt in:
| |
The server has to cooperate too. Once credentials are involved it can’t answer with the wildcard Access-Control-Allow-Origin: *. It must echo the specific origin and add Access-Control-Allow-Credentials: true. This is the CORS negotiation covered in What Is CORS and How to Fix CORS Errors, and it’s where most “my cookie isn’t showing up” bugs live.
How to sync localStorage across tabs with the storage event
When localStorage changes, the browser fires a storage event in every other tab on the same origin, not in the tab that made the change. That lets you log a user out everywhere the moment one tab calls localStorage.removeItem("token"):
| |
sessionStorage never fires this event across tabs. No other tab can see it.
When not to use cookies, localStorage or sessionStorage
Cookies are wrong for anything over a few kilobytes and for data the server never reads, like a UI theme or a half-written comment. You’d be re-uploading it on every request for nothing.
localStorage is wrong for anything that must survive an XSS bug: session tokens, API keys, anything you’d hate to see in a breach report that says “attacker ran console.log(localStorage).” It’s also wrong for data the server acts on. It never leaves the browser unless your code sends it, and once you write that code you’ve rebuilt a cookie without HttpOnly, Secure or SameSite.
sessionStorage is wrong for anything shared between tabs or expected to be there after the user closes and reopens the browser. It fits one shape of problem: state that belongs to this tab, for this visit. The current step of a multi-page checkout is the classic case, since it shouldn’t leak into a second tab the user opened to compare prices.
Past a few megabytes, or once you need queries instead of key lookups, none of the three fits. Use IndexedDB before you try to cram a few thousand JSON records into localStorage and hit the quota.
Which one to use for tokens, carts and preferences
| Data | Mechanism | Why |
|---|---|---|
| Session token, auth state | HttpOnly + Secure cookie | Server needs it; JS must not be able to read it |
| CSRF token | Cookie (double-submit) or server session | Needs to round-trip with the request it protects |
| Theme, locale, UI preference | localStorage | Small, not sensitive, should persist |
| Multi-step form draft, wizard state | sessionStorage | Per-tab, disposable, no server round trip needed |
| Shopping cart (unauthenticated) | localStorage, or server-side tied to a session cookie | Depends on whether it must survive a cleared browser |
| Large structured data, offline cache | IndexedDB | Needs capacity and querying none of the three provide |
If your server has to trust it, put it in an HttpOnly, Secure cookie with an explicit SameSite. Keep localStorage for data you’d be fine handing to any script on your page.