Command Palette

Search for a command to run...

Hectal
PHASE 21Advanced ~33 min· topic 4 of 6

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.

.envwhole filebash
# 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.

terminalwhole filebash
$ 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: good

03Let 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).

Let the server set an httpOnly cookiediagram
Rendering diagram…

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.

api.jswhole filejavascript
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.

Model auth state with three statusesdiagram
Rendering diagram…

Try it yourself

  1. 1

    Hide the token from storage

    In the first example, remove the localStorage.setItem line. Predict the first log line. ([]: there's nothing left for the snooping script to read.)

  2. 2

    Start logged in

    In the guard example, change let serverSession = null; to let serverSession = { name: "Ravi", role: "member" };. Predict the logs: does the login page ever appear? (No: loading then authenticated, and the page shows Ravi's orders; the scripted click then logs out.)

  3. 3

    Switch to Strict

    In the CSRF model, add "Strict" to the list of SameSite values. The model treats anything that isn't None or Lax as never sent cross-site. Predict the evil link: GET row for Strict. (No cookie: a user following a link from another site arrives logged out, which is why Lax is the usual choice.)

Code & diagrams

Any script on the page can read localStorage New tab

`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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

localStorage it can read: [ 'auth_token = eyJhbGciOi.demo.token-in-storage' ]
can it see memoryToken by name? false
Auth state from the server, with a route guard React 19+ New tab

The 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

Press Run to see the page.

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 out
SameSite, Origin and CSRF tokens, as a model New tab

With `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

Press Run to see the page.

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
A "secret" key in a Vite app is in the bundle for everyonebash
$ 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".

terminal
$ grep dist/assets
── what you'll see ──
dist/assets/index-BX3k9q2L.js:sk_live_51... (the secret key is now in every visitor's download)

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.

terminal
$ DevTools console
── what you'll see ──
> localStorage.setItem("user", JSON.stringify({ role: "admin" }))
(reload) The admin panel appears for an ordinary user, and if the server also trusts a role sent by the client, admin actions succeed.

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 has Secure, Path=/ and no Domain, so a compromised subdomain can't overwrite your session cookie. __Secure- only requires Secure.

  • ▸

    SameSite is about sites (scheme plus registrable domain, such as https://example.com), not origins: app.example.com and api.example.com are the same site. So SameSite doesn't protect you from a compromised sibling subdomain; Origin checks 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 is cross-site.

  • ▸

    Calling a JSON API with a custom header such as Content-Type: application/json triggers 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 misconfigured Access-Control-Allow-Origin with credentials undoes it.

Remember this

  1. 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_** as import.meta.env.VITE_NAME; Next.js exposes only **NEXT_PUBLIC_** variables as process.env.NEXT_PUBLIC_NAME (Topic 23.2 covers configuration in depth). The prefix is a safety catch so you don't leak DATABASE_URL by 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. 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 an Authorization header, 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.cookie doesn'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. 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. 4

    CSRF (cross-site request forgery) happens because cookies are sent automatically: a page on evil.example submits a hidden form to shop.example/api/transfer, and the browser attaches the user's shop cookie. Defences, in layers: (1) set the cookie with **SameSite=Lax** (or Strict), so the browser doesn't send it on cross-site POSTs, fetches or iframes; Lax still sends it when the user clicks a normal link to your site, which keeps them logged in. Chrome treats cookies without SameSite as Lax, but set it explicitly. (2) Never change anything on GET. (3) On the server, check the **Origin** header (or Sec-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 check Origin for you (Topic 21.5). A full cookie looks like Set-Cookie: __Host-session=…; HttpOnly; Secure; SameSite=Lax; Path=/.

  5. 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 a status of loading, authenticated or anonymous, 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 or isAdmin in localStorage and trust it.

  6. 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 (a BroadcastChannel or the storage event). Handle 401 responses in one place: when any request says the session expired, reset auth state and send the user to login with a safe next (Topic 21.3).

Explain it without notes

01

Why can't a React app keep a secret, and what do the VITE_ and NEXT_PUBLIC_ prefixes mean?

02

Compare storing an auth token in localStorage, in memory and in an httpOnly cookie.

03

What is CSRF and how do you defend a cookie-based app against it?

04

How should a React app represent auth state, and what must never depend on it?

Practice

01

Write an apiFetch wrapper that sends cookies, adds a CSRF header for non-GET requests and calls onUnauthorized() on a 401.

02

Write the Set-Cookie header a login endpoint should send for a session id, and explain each attribute.

03

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.