Topic 21.4
Secrets, Tokens and Auth in the Browser
In one line
Everything shipped to the browser can be read by anyone, so secrets stay on servers; VITE_ and NEXT_PUBLIC_ variables are public by design. Store login sessions where scripts can't read them (httpOnly cookies), defend cookies against CSRF with SameSite, Origin checks and tokens, and treat auth state in React as a display hint the server always re-checks.
Think of it like this
A shop window. You can put anything in the window, and the shop is proud of it, but you'd never put the safe's key there: everyone walking past can see it. Your React bundle is the shop window. Every visitor downloads every line of it and can read it with DevTools, even the parts that are minified. So keys that must stay secret (database passwords, payment secret keys, AI API keys) belong in the back room: on a server your React code calls.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Bundle
- The JavaScript files your build produces and every visitor downloads. Anyone can read them.
- Environment variable
- A named setting given to a program from outside its code, such as
API_URL. - VITE_ / NEXT_PUBLIC_
- Name prefixes that tell Vite or Next.js to copy a variable into the browser bundle, making it public.
- Token
- A string the browser sends to prove who it is, such as a session id or a JWT.
- JWT
- JSON Web Token: a signed token that carries data (user id, expiry). Signed, not encrypted: anyone can read its contents.
- httpOnly cookie
- A cookie the browser sends automatically but JavaScript on the page can't read.
- CSRF
- Cross-site request forgery: another site makes the user's browser send a request to your site, cookies included.
- SameSite
- A cookie setting (
Strict,Lax,None) that controls whether the cookie is sent on requests started by other sites. - Origin header
- A header the browser adds saying which site started the request. Pages can't change it.
- BFF (backend for frontend)
- A small server that sits next to your front end, holds secrets and calls other services for it.
Step by step
01Put only public values behind the prefix
Split your .env into what the browser may know and what only the server may know. Vite loads .env files and exposes only the VITE_ names to client code; the others are simply undefined there.
# Public: ends up in dist/assets/*.js
VITE_API_URL=https://api.shop.example
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51...
# Server only: never read these in React code
STRIPE_SECRET_KEY=sk_live_...
DATABASE_URL=postgres://...02Check what your build really contains
After a build, search the output for anything that looks secret. Make this a CI step: a found secret fails the build. If a secret ever shipped, rotate it (make a new one and revoke the old one): deleting it from the code doesn't remove it from caches, old deploys or anyone's downloads.
$ npm run build
$ grep -o "pk_live_[A-Za-z0-9]*" dist/assets/*.js
dist/assets/index-BX3k9q2L.js:pk_live_51... # expected: publishable keys are public
$ grep -lE "sk_live_|DATABASE_URL" dist/assets/*.js
$ # nothing: good03Let the server set an httpOnly cookie
Your login form posts the password to the server. The server checks it and answers with a Set-Cookie header. Your React code never sees the session: it only learns who is logged in by calling /api/me, and fetch sends the cookie for same-origin requests by default (credentials: "include" is needed for a different-origin API).
04Defend state-changing requests against CSRF
With SameSite=Lax, a cross-site form POST arrives without the cookie. Still check Origin on the server and send a CSRF token from your app, because older browsers, subdomains you don't control and SameSite=None cookies (needed for some embeds) weaken the first layer.
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
export function apiPost(path, body) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify(body),
});
}05Model auth state with three statuses
loading (we're asking), authenticated (with the user), anonymous. Guards wait while loading, redirect when anonymous and remember where the user was going.
Try it yourself
- 1
Hide the token from storage
In the first example, remove the
localStorage.setItemline. Predict the first log line. ([]: there's nothing left for the snooping script to read.) - 2
Start logged in
In the guard example, change
let serverSession = null;tolet serverSession = { name: "Ravi", role: "member" };. Predict the logs: does the login page ever appear? (No:loadingthenauthenticated, and the page shows Ravi's orders; the scripted click then logs out.) - 3
Switch to Strict
In the CSRF model, add
"Strict"to the list of SameSite values. The model treats anything that isn'tNoneorLaxas never sent cross-site. Predict theevil link: GETrow forStrict. (No cookie: a user following a link from another site arrives logged out, which is whyLaxis the usual choice.)
Code & diagrams
`localStorage` is one shared box for every script on your origin, so the stored token is one loop away. The module variable is not on `window`, so a script can't find it by name (though code that runs in your page can still make requests as the user). An httpOnly cookie goes further: no page script can read it at all. In this preview `localStorage` is in memory and resets on every Run.
Preview
Console
Nothing logged yet.Expected console output
localStorage it can read: [ 'auth_token = eyJhbGciOi.demo.token-in-storage' ]
can it see memoryToken by name? falseThe app starts on `/orders`, shows "Checking your session…" while `loading`, then the guard redirects to `/login` and remembers `/orders`. After login it goes back there. This guard only decides what to **show**: the real `/api/orders` endpoint must still check the cookie itself, because anyone can skip this JavaScript.
Preview
Console
Nothing logged yet.Expected console output
auth status: loading
auth status: anonymous
page shows: Log in as Asha
auth status: authenticated
page shows: Orders for AshaLog outWith `SameSite=None` the evil form's POST carries the cookie, and only the server's `Origin` check (and the CSRF token behind it) stops it. With `Lax` the cookie isn't even sent. The evil **link** still arrives logged in under `Lax`, which is fine as long as GET requests never change anything. Real browsers decide "same site" by the registrable domain, which this model simplifies.
Preview
Console
Nothing logged yet.Expected console output
None | our app: POST with token | cookie sent | 200 changed
None | evil form: POST | cookie sent | 403 wrong Origin
None | evil link: GET | cookie sent | 200 read only
Lax | our app: POST with token | cookie sent | 200 changed
Lax | evil form: POST | no cookie | 401 not logged in
Lax | evil link: GET | cookie sent | 200 read only$ cat .env
VITE_OPENAI_KEY=sk-proj-abc123... # a mistake: the prefix makes it public
$ cat src/ask.js
fetch("https://api.openai.com/v1/responses", {
headers: { Authorization: "Bearer " + import.meta.env.VITE_OPENAI_KEY },
...
});
$ npm run build && grep -o "sk-proj-[a-z0-9]*" dist/assets/*.js
dist/assets/index-D4f8Qm1x.js:sk-proj-abc123
# Anyone can open DevTools > Sources (or the Network tab) and copy it.
# Fix: remove the VITE_ prefix, call your own endpoint (/api/ask) from React,
# and let that server code add the key. Then rotate the leaked key.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Put a secret behind the public prefix
Rename STRIPE_SECRET_KEY to VITE_STRIPE_SECRET_KEY because React code "couldn't see it".
Break #2
Trust the client's role
Store { role: "admin" } in localStorage after login and show the admin panel (and allow its API calls) when it says admin.
Myth vs fact
Myth
Minifying or obfuscating the bundle hides keys.
Fact
Strings survive minification unchanged, and every request the app makes (with its headers) is visible in the Network tab.
Myth
httpOnly cookies make XSS harmless.
Fact
They stop the token being stolen, but an attacker's script can still make requests as the user while the page is open. You still need to prevent XSS.
Myth
JWTs are encrypted, so it's fine to put data in them.
Fact
A standard JWT is only signed. Its payload is base64url-encoded JSON that anyone can decode. Don't put secrets or private data in it.
Myth
A protected route keeps data safe.
Fact
Route guards are UX. The API behind them must check authentication and authorisation itself.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The
__Host-cookie name prefix makes the browser reject the cookie unless it hasSecure,Path=/and noDomain, so a compromised subdomain can't overwrite your session cookie.__Secure-only requiresSecure. - ▸
SameSiteis about sites (scheme plus registrable domain, such ashttps://example.com), not origins:app.example.comandapi.example.comare the same site. SoSameSitedoesn't protect you from a compromised sibling subdomain;Originchecks and CSRF tokens do. - ▸
Sec-Fetch-Site(same-origin,same-site,cross-site,none) is sent by all current browsers and is a simple, strong server-side CSRF filter: reject state-changing requests where it iscross-site. - ▸
Calling a JSON API with a custom header such as
Content-Type: application/jsontriggers a CORS preflight for cross-origin requests, which blocks simple form-based CSRF, but only if your CORS config is strict. Don't rely on it alone; a misconfiguredAccess-Control-Allow-Originwith credentials undoes it.
Remember this
- 1
Nothing in the bundle is secret. Build tools copy some environment variables into your JavaScript at build time. Vite exposes only variables whose names start with **
VITE_** asimport.meta.env.VITE_NAME; Next.js exposes only **NEXT_PUBLIC_** variables asprocess.env.NEXT_PUBLIC_NAME(Topic 23.2 covers configuration in depth). The prefix is a safety catch so you don't leakDATABASE_URLby accident, but it means the opposite too: anything with the prefix is public. Fine for public things: a Stripe *publishable* key, a Maps key restricted by domain, an analytics id, the API base URL. Never for secret keys. If the browser needs to use a secret service, add a small server endpoint (a backend for frontend, BFF) that holds the key and calls the service for it, with rate limits. - 2
Where to keep the login session. After login, the browser must prove who it is on every request, with a token (a long random string, or a signed JWT) or a session id. The three common places are: (1) **
localStorage**: survives reloads and is easy to send in anAuthorizationheader, but any script on the page can read it, so one XSS bug or one compromised npm package can copy the token and use it from anywhere until it expires. (2) Memory (a variable or React state): scripts can't find it by scanning storage, but it's lost on reload, so you need a way to get a new one (usually a refresh request that uses an httpOnly cookie). (3) An httpOnly cookie set by the server: JavaScript cannot read it at all (document.cookiedoesn't show it), and the browser attaches it to requests automatically. That makes it the best default for web apps, with one new problem: CSRF. - 3
An honest note on XSS: if an attacker's script runs in your page, it can use the session whatever you chose, by calling your API from the page while the user is there. What httpOnly cookies prevent is theft: the attacker can't copy the session out and keep using it later. That's a big difference, but it's why stopping XSS (Topics 21.1–21.3, 21.6) comes first.
- 4
CSRF (cross-site request forgery) happens because cookies are sent automatically: a page on
evil.examplesubmits a hidden form toshop.example/api/transfer, and the browser attaches the user's shop cookie. Defences, in layers: (1) set the cookie with **SameSite=Lax** (orStrict), so the browser doesn't send it on cross-site POSTs, fetches or iframes;Laxstill sends it when the user clicks a normal link to your site, which keeps them logged in. Chrome treats cookies withoutSameSiteasLax, but set it explicitly. (2) Never change anything on GET. (3) On the server, check the **Origin** header (orSec-Fetch-Site) of every state-changing request; pages can't fake it. (4) Use a CSRF token: a random value the server gives your own pages, which your fetch sends in a header; other sites can't read it. Frameworks such as Next.js Server Actions checkOriginfor you (Topic 21.5). A full cookie looks likeSet-Cookie: __Host-session=…; HttpOnly; Secure; SameSite=Lax; Path=/. - 5
Auth state in React is a copy of what the server knows, used to decide what to show. Load it from the server (
GET /api/me) when the app starts, keep it in context or a query cache with astatusofloading,authenticatedoranonymous, and render a neutral "checking…" state while loading so protected pages don't flash. Route guards (RequireAuth) and hidden buttons are user experience, not security: anyone can edit the JavaScript or call your API directly, so the server must check authentication and permissions on every request. Never store a role orisAdmininlocalStorageand trust it. - 6
Logging out properly means: ask the server to end the session (it clears or expires the cookie), clear every client cache that holds the user's data (React state, TanStack Query's
queryClient.clear(), Redux store), and tell other tabs (aBroadcastChannelor thestorageevent). Handle 401 responses in one place: when any request says the session expired, reset auth state and send the user to login with a safenext(Topic 21.3).
Explain it without notes
Why can't a React app keep a secret, and what do the VITE_ and NEXT_PUBLIC_ prefixes mean?
Compare storing an auth token in localStorage, in memory and in an httpOnly cookie.
What is CSRF and how do you defend a cookie-based app against it?
How should a React app represent auth state, and what must never depend on it?
Practice
Write an apiFetch wrapper that sends cookies, adds a CSRF header for non-GET requests and calls onUnauthorized() on a 401.
Write the Set-Cookie header a login endpoint should send for a session id, and explain each attribute.
A React app calls a weather API with a secret key from import.meta.env.VITE_WEATHER_KEY. Describe the fix in code.
Trade-offs
- ↔
httpOnly cookies are the safest token storage for browsers but tie you to cookie rules (same-site APIs, CSRF defences, CORS with credentials). Bearer tokens in headers are simpler for cross-domain APIs and mobile clients but are readable by any script that can read where you keep them.
- ↔
Short-lived access tokens with refresh limit the damage of a leak but add complexity (refresh races, retries). Long sessions are simpler and friendlier but need server-side revocation.
Done when you can
I keep secrets on the server and know that
VITE_/NEXT_PUBLIC_values are public.I can compare localStorage, memory and httpOnly cookies for sessions.
I can write a secure session cookie and explain each attribute.
I can explain CSRF and layer SameSite, Origin checks and CSRF tokens against it.
I model auth state with loading/authenticated/anonymous and never trust it for security.
I log out by ending the server session, clearing caches and telling other tabs.