The Browser Contract
Where Do Tokens Live? XSS, CSRF and the Storage Trade-off
localStorage lets XSS steal tokens. Cookies let CSRF attacks use you. Safe patterns: HttpOnly cookies with SameSite or CSRF tokens, access tokens kept in memory, and a backend-for-frontend.
On this page
In one line: there is no perfectly safe place for a browser credential. localStorage exposes it to XSS. A cookie exposes you to CSRF. (Both attacks are explained below.) A senior answer picks a pattern, says what risk is still open, and says what reduces that risk.
A credential is data that proves who you are, such as a session ID or a token. This lesson separates "I store the JWT in localStorage because the tutorial did" from "here is the threat model and the trade-off". (A threat model is a written list of who might attack you, how, and what you do about it.) You need to know two attacks and four patterns.
The two attacks
XSS: cross-site scripting
XSS (cross-site scripting) is an attack where the attacker gets their JavaScript to run on your origin. (An origin is the scheme, host and port of a site, such as https://app.example.com.) This can happen through user content that was not cleaned, a weak dependency, a hacked third-party script, or a place in the DOM (the page as a tree of objects that JavaScript can change) that you forgot to protect. From then on, the browser treats the script as if it were you.
What the script can do:
- Read anything JavaScript can read:
localStorageandsessionStorage(browser stores for text), cookies that are not HttpOnly (HttpOnlyis a cookie setting that hides the cookie from JavaScript), tokens in variables, and the DOM (including personal data on the screen). - Act as the user: call your API with
fetch. The browser adds the session cookie automatically, HttpOnly or not. - Change the page: show a fake login form and capture the password.
CSRF: cross-site request forgery
CSRF (cross-site request forgery) is an attack where the attacker does not run code on your site. Instead, they make the victim's browser send a request to your site from another place. The browser helpfully adds the cookies.
<!-- On evil.example, while the victim is logged in to bank.example --><form action="https://bank.example/transfer" method="POST" id="f"> <input name="to" value="attacker" /><input name="amount" value="5000" /></form><script>document.getElementById("f").submit()</script>The attacker can cause an action, but cannot read the response. (The same-origin policy blocks reading. This is a browser rule that stops a page from one origin from reading data that belongs to another origin.) So CSRF aims at endpoints that change data and trust only "the cookie is there".
Bearer tokens in an Authorization header are not sent automatically. (A bearer token is a token where whoever holds it is let in. The Authorization header is the request header that carries it.) Your code must add them. So a forged request from another site has no token. This is why the localStorage-plus-header pattern has no CSRF problem. All of its risk is in XSS.
What each storage choice exposes
| Where the credential lives | Stolen by XSS? | Used by XSS (acting as the user)? | Open to CSRF? |
|---|---|---|---|
localStorage / sessionStorage token | Yes, and the attacker can reuse it from their own machine | Yes | No (not sent automatically) |
| Cookie that is not HttpOnly | Yes | Yes | Yes |
| HttpOnly cookie | No (cannot be read) | Yes (the browser adds it to the script's requests) | Yes, unless you defend |
| JS variable in memory | Harder, but a script on the page can still call the same API | Yes | No |
Read the HttpOnly row with care. It is the most misunderstood fact in this topic: HttpOnly stops token theft, not account abuse. An XSS script cannot take the cookie away. So the attacker cannot use it later from another place. But the script can still do actions while the victim's tab is open. HttpOnly makes the damage smaller. Preventing XSS is the real control.
Defending against CSRF (when you use cookies)
Use several layers. No single one is the full answer.
SameSitecookies.SameSiteis a cookie setting that controls whether the cookie is sent when the request starts from another site. (A site is the registrable domain, such asexample.com, with all its subdomains.)Lax: the cookie is not sent on cross-sitePOST,fetch, iframes (pages embedded inside a page) or images. It is sent when the user goes to your site from another site with a safe method such asGET(for example, by clicking a link). This move is called a top-level navigation: the address bar changes.Strict: the cookie is never sent on cross-site requests. This includes clicking a link from another site, so the user may look logged out when they arrive.None: always sent. It requiresSecure. Use it only when you really need cross-site cookies.
- CSRF tokens. A CSRF token is a random secret value that the server gives to your own page. The server makes an unpredictable token tied to the session. It requires this token on every request that changes data, in a header or a hidden form field. The attacker's site cannot read the token (because of the same-origin policy), so it cannot forge the request. There are two common types: the synchronizer token (the server stores it and compares) and the signed double-submit cookie (the token is sent both in a cookie and in a header, and the server checks that they match and that the signature is valid).
- Check
Origin(andReferer) on unsafe requests.Originis a request header that tells the server which origin the request came from.Referer(the spelling is a historic typo) is a header with the URL of the page that made the request. An unsafe request is one that changes data, such asPOST. Browsers setOriginon cross-sitePOSTrequests, and an attacker's page cannot fake it. - Fetch Metadata headers are headers that the browser adds to say how a request started.
Sec-Fetch-Sitesays if it came fromsame-origin,same-site,cross-siteornone. Reject requests that change data when it sayscross-site. - Require a custom header (for example
X-Requested-With) for JSON APIs. A page on another site cannot add a custom header unless the browser first sends a CORS preflight. CORS (Cross-Origin Resource Sharing) is the set of rules for when a site may call another origin. A preflight is an extraOPTIONSrequest that asks the other server for permission first. Your server refuses it, so the forged request is never sent. - Never change data on
GET. Otherwise<img src="/delete?id=3">would work, even withLax.
Where SameSite=Lax falls short
- Same-site is not same-origin.
a.example.comandb.example.comare the same site but different origins. They are sibling subdomains (two subdomains of the same parent domain). If an attacker finds XSS (or takes over a subdomain) on a sibling subdomain, they can send logged-in requests to your site with the cookie attached. GETrequests with side effects still travel along when the user goes to your site from another site.- Browser quirks: Chrome treats a cookie with no
SameSitevalue asLax, but for the first 2 minutes it still sends it on cross-site top-levelPOSTrequests. Firefox and Safari do not default toLax. Set the value explicitly. - Older browsers or browsers without support ignore it. Keep a CSRF token for sensitive actions.
Defending against XSS (whatever you store)
- Escape output by default. (Escaping means turning special characters, such as
<, into harmless text so they show as text and do not run as code.) React escapes values you put in the page. The danger zones aredangerouslySetInnerHTML,innerHTML,hrefandsrcvalues from users (javascript:URLs), and markdown that was not sanitised (cleaned) (Lesson 07). - Sanitise HTML that you must show. Sanitising means removing dangerous parts from HTML. Use a sanitiser with an allow-list (it keeps only tags you list as safe), such as DOMPurify (a popular sanitising library).
- Content Security Policy (CSP) is a response header that lists which sources of scripts and other files the page may use. It blocks inline and untrusted scripts, so injected code cannot run.
- Trusted Types is a browser feature that makes dangerous DOM places (like
innerHTML) reject plain strings. They accept only values that your own safe code has approved. - Check your dependencies and third-party scripts. A hacked script is XSS that you invited in.
Four patterns that hold up
Pattern 1: HttpOnly session cookie (the strong default for first-party apps)
Set-Cookie: __Host-sid=…; Path=/; Secure; HttpOnly; SameSite=Lax- Token theft by XSS: blocked.
- CSRF: handled by
SameSiteplus a CSRF token or an Origin check on unsafe requests. - Server rendering (building the HTML on the server) and API calls use the same login method. This is the easiest to understand.
Pattern 2: short-lived access token in memory plus HttpOnly refresh cookie (a SPA talking to an API)
A SPA (single-page app) is a site that loads once and then updates the page with JavaScript. An access token is a short-lived token that your API accepts. A refresh token is a long-lived token used only to get a new access token. (See Lesson 02.)
Page load ─▶ POST /auth/refresh (refresh cookie sent automatically, SameSite + CSRF check) ◀─ { accessToken } (kept in a JS variable, never persisted)API calls ─▶ Authorization: Bearer <accessToken>Reload ─▶ the access token is gone, so call /auth/refresh again- XSS cannot read the refresh cookie. The cookie is sent only to the auth endpoint (limit it with
Path=/auth). - The access token is not in storage. To steal it, the attacker needs code running at that moment. It also expires in minutes.
- The cost: one refresh call on every page load, and some extra work when many requests run at the same time.
Pattern 3: backend-for-frontend (BFF)
A backend-for-frontend (BFF) is a small server that you own, built for your frontend. It sits between the browser and the other APIs. The browser never sees an access token. It holds only a session cookie for your own backend. That backend keeps the tokens and calls other APIs for the user.
Browser ──session cookie──▶ Next.js server (BFF) ──Bearer token──▶ Resource APIs │ └─ tokens stored server-side (encrypted session / Redis)- XSS cannot steal tokens, because there are none in the browser.
- You handle refresh, scopes and logging in one place. OAuth (the standard way to let an app get access to an API for a user) has guidance for browser apps, and it recommends this pattern in most cases.
- In Next.js, route handlers (server code that answers HTTP requests) or server actions (server functions that the page can call) can act as the BFF. The other API stays private.
Pattern 4: bearer token in localStorage (and when it is defensible)
This is simple, has no CSRF problem, and works across domains. But any XSS can copy a credential that can be reused. It is acceptable only for low-risk apps. Even then, you need a short expiry, a strict CSP, as few third-party scripts as possible, and a risk that you accept on purpose. OWASP (the Open Worldwide Application Security Project) says session IDs and tokens should not be kept in localStorage or sessionStorage. In an interview, if you choose this pattern, say so and say what risk it leaves open.
| Pattern | Can XSS steal the token? | CSRF risk | Complexity | Good for |
|---|---|---|---|---|
| HttpOnly session cookie | No | Needs a defence | Low | Most first-party web apps |
| Access token in memory + HttpOnly refresh | Hard | The refresh endpoint needs a defence | Medium | SPAs with a separate API |
| BFF | No tokens in the browser | The session cookie needs a defence | Medium | Sensitive apps, many other APIs |
localStorage bearer | Yes | None | Low | Low-risk apps, with an accepted trade-off |
Scenario: the SPA calls an API on another domain
The frontend is on app.example.com. The API is on api.otherdomain.com. What do you do?
- Best: make it same-site. Put the API under your own domain (
api.example.com), or pass it through your Next.js server (a rewrite or a BFF). Then cookies are first-party (set by the same site as the page) andSameSite=Laxworks. A rewrite is a server rule that quietly forwards a path, such as/api/*, to another server. - If it must be cross-site, you need all of these. The cookie needs
SameSite=None; Secure. The request needscredentials: "include"(afetchoption that tells the browser to send cookies on cross-origin requests). The API must sendAccess-Control-Allow-Origin: <exact origin>andAccess-Control-Allow-Credentials: true(Lesson 04). You must add CSRF tokens. And you will have to deal with browsers that block third-party cookies (cookies set by a site other than the one in the address bar). - Or use bearer tokens with the memory-plus-refresh pattern or the BFF pattern. You accept the extra parts.
Common mistakes
- "HttpOnly makes us safe from XSS." It only stops reading the cookie.
- "We use JWT in a cookie, so no CSRF." A cookie is sent automatically, so CSRF applies no matter what is inside it.
- Relying on
SameSite=Laxalone while sibling subdomains host user content. - Endpoints that change data on
GET. - Using CORS as CSRF protection. CORS limits who can read responses. A simple cross-site
POSTis still sent. - Putting the refresh token in
localStorage. It lives the longest and is the most valuable secret. - Skipping cleanup at logout: not revoking the refresh token on the server.
Interview questions
Why does OWASP say not to put JWTs in localStorage?
localStorage can be read by any JavaScript on the origin. One XSS bug, or one hacked third-party script, lets an attacker copy the token. They can then reuse it from anywhere until it expires. Also, you cannot protect it from scripts the way you can with an HttpOnly cookie.
Does an HttpOnly cookie stop XSS?
No. It stops cookie theft, not session abuse. The injected script can still call your API, and the browser will add the cookie. HttpOnly makes the damage smaller (no long-term theft). But you still must prevent XSS with escaping, sanitising and CSP.
How does SameSite=Lax mitigate CSRF, and where does it fall short?
It holds back the cookie on cross-site sub-requests such as POST, fetch, images and iframes. This covers the classic forged form. It falls short in three cases. One is an attacker on the same site (a sibling subdomain). Another is a GET request that changes data and arrives through top-level navigation. The third is clients with different or older behaviour. Add a CSRF token or an Origin check for sensitive actions.
Scenario: your SPA calls an API on another domain. How do you store auth safely?
First choice: make the API same-site through your domain or a proxy, and use an HttpOnly Lax session cookie. If you cannot, use a BFF that keeps the tokens on the server. Or keep a short-lived access token in memory with an HttpOnly refresh cookie limited to the auth path. If you must use cross-site cookies, add SameSite=None; Secure, CORS with the exact origin and credentials, and CSRF tokens.
What is a backend-for-frontend and why use one for auth?
It is a server you own that sits between the browser and other APIs. The browser holds only a session cookie for the BFF. The BFF stores and refreshes the OAuth tokens and calls the APIs for the user. Tokens never reach JavaScript. This removes the main way XSS steals tokens, and it puts the security logic in one place.
How does a CSRF token work, and why can't the attacker's page read it?
The server makes an unpredictable value tied to the user's session. It puts the value in the page or returns it in a response. Requests that change data must send it back (in a header or a field). Another origin cannot read your pages or API responses, because of the same-origin policy. So it cannot learn the value, and it cannot forge a valid request.
Is CORS a CSRF defence?
No. CORS decides whether a page may read a response from another origin. A form POST from another site is sent anyway. A custom header that triggers a preflight may block some forged requests by chance. But you should not rely on CORS for CSRF protection.
Is JWT-in-a-cookie vulnerable to CSRF?
Yes. Cookies are added automatically. So any login that uses cookies needs CSRF defences, whatever the cookie contains.
Scenario: a security review flags tokens in localStorage on a live SPA. How do you migrate without logging everyone out?
Add the new method next to the old one. Add an HttpOnly refresh-token cookie (or a backend-for-frontend session). Keep access tokens in memory. On the first page load, swap the old localStorage token once: send it to a migration endpoint, get the cookie session, then delete the stored token. Roll it out slowly behind a flag. Add CSRF protection for the cookie endpoints, and watch for login failures. A flag (feature flag) is a switch that turns a feature on for some users only.
Key takeaways
- XSS runs the attacker's code as you. CSRF makes your browser send requests you did not mean to send. They need different defences.
localStoragetokens: no CSRF, but XSS can steal them. HttpOnly cookies: cannot be stolen, but they need CSRF defences, and XSS can still use the session.- Strong defaults: HttpOnly session cookie (first-party), access token in memory + HttpOnly refresh cookie (SPA + API), or a BFF (sensitive apps).
- Make cross-domain APIs same-site when you can.
- Always use layers: escaping and sanitising, CSP,
SameSite, CSRF tokens or Origin checks, and short token lifetimes.
What's next
Lesson 04: CORS Explained. Calls to APIs on other origins keep coming up. Next we explain exactly what the browser blocks, what triggers a preflight, and why your request "works in Postman" but fails in Chrome.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- XSS, CSRF, Clickjacking and Security Headers · Security for Developers
- API Authentication and Authorization: API Keys, OAuth 2.0 and Scopes · API Design
- Authentication and Authorization on the Server · Backend in Depth
Part of the study guide
- Full-Stack Developer Interview Guide: Questions and Answers
- Web Security Guide for Developers: OWASP Top 10 Basics
Related series