Cookies, CSRF, Secrets, and Supply-Chain Risk
Understand secure cookie attributes and token storage, how CSRF differs from XSS and how CORS relates to it, why frontend code can never hold real secrets, how dependency and supply-chain vulnerabilities reach your bundle, and how to validate redirects and file uploads.
The previous lesson covered attacks that inject code into your page. This lesson covers the second broad category: attacks and mistakes that abuse trust relationships — between the browser and your server (cookies, CSRF), between your app and other origins (CORS), between your bundle and the packages it depends on (supply chain), and between your build output and whatever you assumed was 'hidden' inside it (frontend secrets). None of these bugs require injecting a single line of script; they exploit how browsers, package registries, and build tools are designed to behave.
Secure cookies: HttpOnly, Secure, and SameSite
A cookie is just a small key-value pair the browser stores and automatically re-attaches to future requests to the issuing domain — which is exactly what makes it both useful for sessions and dangerous if misconfigured. Three attributes, set by the server in the Set-Cookie header (never from client-side JavaScript for anything sensitive), control most of a cookie's security posture.
HttpOnly tells the browser to hide the cookie from document.cookie entirely — client-side JavaScript, including an XSS payload that manages to run, cannot read or exfiltrate it. Any cookie that holds a session identifier or auth token should always be HttpOnly; this is the single most effective mitigation against session-stealing XSS.
Secure tells the browser to send the cookie only over HTTPS, never over plain HTTP, which prevents it from being read by a network-level attacker on an unencrypted connection (e.g. public Wi-Fi). There is essentially no legitimate reason to omit Secure on any production cookie in 2026.
SameSite controls whether the cookie is attached to cross-site requests. SameSite=Strict never sends it cross-site (safest, but can break legitimate flows like arriving via an external link while already logged in). SameSite=Lax — the modern browser default — sends it on top-level navigation (clicking a link) but not on cross-site subresource requests or non-GET cross-site form submissions, which blocks the classic CSRF pattern of an auto-submitting form on an attacker's page. SameSite=None sends it in all cross-site contexts and requires Secure — use it only when you deliberately need a cookie to work cross-site (e.g. an embedded widget), and pair it with CSRF tokens.
• HttpOnly — invisible to JavaScript/document.cookie; blocks XSS from stealing the session. • Secure — HTTPS-only transmission; blocks network eavesdropping. • SameSite=Strict/Lax — restricts cross-site attachment; primary browser-level CSRF defense. • None of these attributes can be set from React/client JavaScript for a cookie you want to protect — they must come from the server's Set-Cookie header.
Where should auth tokens live?
A recurring React question is 'localStorage or a cookie for my JWT?' The honest answer is: prefer an HttpOnly, Secure, SameSite cookie set by the server, because it is invisible to JavaScript and therefore immune to token theft via XSS. localStorage and sessionStorage, by contrast, are fully readable by any script running on the page — including an injected XSS payload — so storing a bearer token there means a single successful XSS bug becomes full session theft, not just a defacement.
The tradeoff is that cookie-based auth reintroduces CSRF risk (because the browser attaches cookies automatically), which is why it must be paired with SameSite and/or an anti-CSRF token. Token-in-memory (a React context/state variable, never persisted) sits between the two: it survives a page reload worse (you must silently re-authenticate via a refresh-token cookie flow) but is not readable via document.cookie or localStorage inspection, and disappears on tab close.
Whichever you choose, treat the access token's lifetime as short (minutes, not weeks) and rely on a separate, HttpOnly refresh-token cookie to mint new access tokens — this limits how long a stolen token remains useful.
CSRF vs. XSS vs. CORS — three different trust boundaries
These three terms get conflated constantly, so it is worth being precise. XSS is about the browser executing attacker script inside your origin — the attacker's code runs as if it were yours. CSRF (cross-site request forgery) is about the browser sending a legitimate, cookie-authenticated request to your server that the attacker triggered from a different origin, without ever running any code in your origin — e.g. an auto-submitting <form action="https://bank.com/transfer"> hosted on evil.com, relying on the victim's browser to attach their existing session cookie.
CORS is not a vulnerability or an attack — it is a browser mechanism that relaxes the same-origin policy for specific cases, and a misconfigured CORS policy (e.g. Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true, which browsers actually forbid together, or a reflected-origin allowlist that echoes any Origin header back) can allow a malicious site's JavaScript to read responses from your API that it should not be able to read. CORS restricts what a script on another origin can read, not what requests the browser can send — which is exactly why CORS alone does not defend against CSRF; CSRF tokens or SameSite cookies do.
• XSS: attacker code executes inside your origin. • CSRF: browser sends a real, cookie-authenticated request that the attacker triggered from another origin. • CORS misconfiguration: a script on another origin is allowed to read your API's response data it shouldn't see. • Defense pairing: SameSite cookies + CSRF tokens stop CSRF; a strict, non-wildcard CORS allowlist stops cross-origin reads; escaping/sanitization/CSP stop XSS.
Frontend secrets are public — always
Anything shipped in your JavaScript bundle — including anything read from an environment variable at build time and embedded into that bundle (e.g. Vite's import.meta.env.VITE_* or Create React App's REACT_APP_* variables) — is fully readable by anyone who opens browser devtools or simply views the deployed source files. There is no build step, minification, or obfuscation that turns client-side code into a secret; minification changes readability, not confidentiality.
This means API keys for services that should be billed or access-controlled per caller (a paid third-party API, a database connection string, a signing key) must never be embedded in frontend code, full stop — even 'just for now' or 'it's rate-limited anyway.' The correct pattern is a backend endpoint that holds the real secret server-side and exposes only the specific, scoped operation your frontend needs (e.g. your server calls the paid geocoding API and returns just the coordinates, rather than shipping the geocoding API key to the browser).
Some keys are explicitly designed to be public — a Stripe *publishable* key, a Google Maps client-side API key restricted by HTTP referrer, a public analytics write key — and shipping those in your bundle is fine because the service was built assuming public exposure and enforces authorization elsewhere (server-side secret key, referrer/domain restrictions, rate limits). The rule is: assume every string in your frontend bundle is public, and only ship values that are safe under that assumption.
Dependency and supply-chain risk
A React app typically ships hundreds of transitive npm dependencies, and every one of them runs with full access to your build output the moment it's bundled — there is no sandboxing between 'your code' and 'a dependency's code' in a typical webpack/Vite bundle. A compromised or malicious package (via a hijacked maintainer account, a typosquatted package name, or a malicious postinstall script) can exfiltrate environment variables at build time, inject code into your production bundle, or both, and has been the actual root cause of real, large-scale incidents.
Practical mitigations: run npm audit / your package manager's equivalent regularly and treat high/critical findings as blocking; pin exact versions or use a lockfile (already default) and review diffs on dependency bumps rather than blindly accepting Dependabot/Renovate PRs for anything with install scripts; prefer well-maintained packages with a visible security process; and be skeptical of adding a new dependency for something trivial — every added package is added attack surface, not just added functionality.
Open redirects and file-upload security
An open redirect happens when your app takes a URL from a query parameter (e.g. ?returnTo=) and redirects the user there without validating it, which attackers exploit to make a link on your trusted domain forward to a phishing page — because the URL bar briefly (or the link preview) shows your legitimate domain, victims trust it more. Always validate that a redirect target is a relative path or matches an explicit allowlist of your own origins, never redirect to an arbitrary externally-supplied absolute URL.
File uploads handled or previewed in a React app carry two separate risks: what the file's *content* can do (an SVG can carry embedded <script>, so previewing an uploaded SVG with dangerouslySetInnerHTML or an <img> in some contexts can execute script — prefer rendering uploads as an <img> with a Content-Type your server has verified, not just trusted the client-reported MIME type) and what the file's *name/path* can do (a filename like ../../etc/passwd or one containing directory-traversal segments, or a filename that is later reflected onto the page unescaped). Client-side validation of file type/size is a UX nicety only; the server must re-validate content-type by sniffing bytes (not trusting the browser's reported MIME type) and re-validate size, because any client check can be bypassed by calling the upload endpoint directly.
• Open redirect fix: allowlist relative paths or known origins; never redirect(req.query.returnTo) verbatim. • File upload fix: server re-validates MIME type by content sniffing, enforces size limits, and stores uploads outside the web root or behind a Content-Disposition: attachment / restrictive CSP. • Never render an uploaded SVG's raw markup via dangerouslySetInnerHTML — treat it as untrusted HTML because it can be.
A CORS-and-credentials-aware fetch, and a safe redirect check
credentials: 'include' only makes sense when the server's CORS response explicitly names your origin (Access-Control-Allow-Origin cannot be '*' when credentials are used) and sets Access-Control-Allow-Credentials: true. The CSRF token here is a defense-in-depth companion to SameSite cookies, typically read from a meta tag the server rendered or from a dedicated non-HttpOnly cookie designed for this purpose (the 'double-submit cookie' pattern). raw.startsWith('//') is excluded because '//evil.com' is treated by browsers as a protocol-relative absolute URL, a common open-redirect bypass.
// Only ever send credentials to origins you explicitly trust.
async function callApi(path: string) {
const res = await fetch(`https://api.example.com${path}`, {
credentials: "include", // sends the HttpOnly session cookie
headers: { "X-CSRF-Token": getCsrfTokenFromMeta() },
});
if (!res.ok) throw new Error(`API error ${res.status}`);
return res.json();
}
// Safe redirect target validation (open-redirect defense)
function safeRedirectTarget(raw: string | null): string {
if (!raw) return "/";
// Only allow same-origin relative paths, never absolute/external URLs.
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
return "/";
}Code Examples
Any XSS anywhere in the app — even in an unrelated third-party widget sharing the page — can run localStorage.getItem('authToken') and exfiltrate it to an attacker's server, turning any injection bug into full account takeover.
Common mistake: Persisting bearer tokens in localStorage/sessionStorage makes every XSS bug in the app a full session-theft bug.
Because the session cookie is HttpOnly, React's JavaScript never has access to it, so an XSS payload has nothing to read from document.cookie or localStorage. SameSite=Lax additionally blocks the cookie from being attached to most cross-site CSRF attempts.
Set-Cookie: session=<opaque-id>; HttpOnly; Secure; SameSite=Lax
Using GET for a state-changing action, combined with cookies that lack SameSite protection, means the browser will trigger the action from a completely different site with zero interaction beyond loading a page — the classic CSRF pattern. Fix: use POST/DELETE for mutations, set SameSite=Lax or Strict, and require a CSRF token that a foreign page cannot read or forge.
Common mistake: GET requests that mutate state are trivially triggerable cross-site via an <img>, <link>, or auto-navigation, with no CSRF token or SameSite protection to stop them.
Blindly reflecting the incoming Origin header back as Access-Control-Allow-Origin defeats the purpose of CORS entirely when combined with credentials — it tells the browser 'any site may read this authenticated response.' An explicit allowlist keeps the browser's same-origin protections meaningful.
// VULNERABLE pattern
async function login(credentials: LoginRequest) {
const { token } = await api.post("/login", credentials);
localStorage.setItem("authToken", token); // readable by ANY script on the page
}
function useAuthHeader() {
return { Authorization: `Bearer ${localStorage.getItem("authToken")}` };
}// Server response includes:
// Set-Cookie: session=<opaque-id>; HttpOnly; Secure; SameSite=Lax; Path=/
async function login(credentials: LoginRequest) {
// The browser stores the cookie itself; React never sees or handles it.
await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
}
async function fetchProfile() {
// The cookie is attached automatically by the browser; no Authorization header needed.
const res = await fetch("/api/me", { credentials: "include" });
return res.json();
}// Server (vulnerable): GET /api/account/delete?confirm=true
// deletes the logged-in user's account using only their session cookie.
// Attacker's page (evil.com):
// <img src="https://app.example.com/api/account/delete?confirm=true">
//
// The victim's browser automatically attaches their session cookie
// to that image request — no JS execution or user click needed at all.// Server (vulnerable) CORS headers:
// Access-Control-Allow-Origin: <reflects whatever Origin header was sent>
// Access-Control-Allow-Credentials: true
//
// This lets ANY origin — including evil.com — make a credentialed
// fetch() to your API and read the authenticated response.
// Fixed: an explicit allowlist, never a blanket reflection
const ALLOWED_ORIGINS = ["https://app.example.com", "https://staging.example.com"];
function corsHeaders(requestOrigin: string) {
if (!ALLOWED_ORIGINS.includes(requestOrigin)) return {};
return {
"Access-Control-Allow-Origin": requestOrigin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
};
}CSRF vs. XSS: two different trust exploits
XSS steals identity by running code in your origin; CSRF abuses identity by riding the browser's automatic cookie attachment.
XSS:
evil script -> runs INSIDE your origin -> reads DOM, tokens, calls your API as "you"
CSRF:
evil.com page -> browser sends a REAL request to your API
(cookie auto-attached) -> no code runs in your origin at all
-> defended by SameSite cookies + CSRF token, not by escapingImportant Rules
Set auth/session cookies as HttpOnly, Secure, and SameSite=Lax (or Strict) from the server — never from client JS.
Never persist bearer tokens in localStorage/sessionStorage if you can use an HttpOnly cookie instead.
Use POST/PUT/DELETE (never GET) for any state-changing endpoint, and require a CSRF token alongside SameSite cookies.
Set an explicit CORS origin allowlist server-side; never reflect the Origin header unconditionally, especially with credentials.
Never embed a private/billable API key, DB credential, or signing secret in frontend code or a VITE_*/REACT_APP_* env var.
Audit dependencies regularly and review what a new package (and its install scripts) actually needs access to.
Validate redirect targets against an allowlist of relative paths/known origins to prevent open redirects.
Re-validate uploaded file type and size server-side by content, never trust the client-reported MIME type alone.
Why this matters beyond the demo app
Several high-profile npm supply-chain incidents have involved a popular package's maintainer account being compromised and a malicious version published that specifically scraped environment variables and exfiltrated them during CI/CD builds — meaning a 'frontend-only' React project's build pipeline secrets (deploy keys, npm tokens) were stolen even though the vulnerability had nothing to do with application logic at all. This is why dependency review and build-time secret hygiene are treated as first-class security concerns, not just runtime code review.
localStorage.setItem('token', jwt);
Server sets Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax and React never touches the token directly.
Any token readable by JavaScript is readable by an XSS payload; HttpOnly cookies are invisible to JS and therefore immune to that specific theft vector.
const key = import.meta.env.VITE_STRIPE_SECRET_KEY;
Keep the Stripe secret key server-side only; expose just the publishable key (designed for public use) via VITE_STRIPE_PUBLISHABLE_KEY.
Every VITE_*/REACT_APP_* variable is embedded verbatim into the shipped bundle and is trivially visible to anyone; only values explicitly designed to be public belong there.
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Origin', isAllowed(req.headers.origin) ? req.headers.origin : '');
Reflecting any incoming Origin unconditionally turns CORS from a restriction into a rubber stamp, letting a malicious site's script read authenticated API responses.
Why is an HttpOnly cookie a stronger place to store a session token than localStorage? A. HttpOnly cookies are encrypted automatically B. HttpOnly cookies are invisible to JavaScript, so an XSS payload cannot read or exfiltrate them C. localStorage is deleted every time the tab closes D. HttpOnly cookies are sent over HTTPS only, and localStorage is not
B. HttpOnly cookies are invisible to JavaScript, so an XSS payload cannot read or exfiltrate them — The defining property of HttpOnly is that document.cookie and all client-side JS APIs cannot read the cookie's value, unlike localStorage which any running script — including an injected XSS payload — can read freely.
What does the SameSite=Lax cookie attribute primarily defend against? A. Cross-site scripting (XSS) B. Cross-site request forgery (CSRF), by restricting when the cookie is attached to cross-site requests C. SQL injection on the backend D. DNS spoofing
B. Cross-site request forgery (CSRF), by restricting when the cookie is attached to cross-site requests — SameSite governs whether the browser attaches the cookie to a request originating from another site; Lax blocks it for cross-site subresource/form requests while still allowing top-level navigation, which blocks the classic CSRF pattern.
Why is it wrong to put a paid third-party API's secret key in a Vite VITE_* environment variable? A. Vite doesn't support environment variables in production B. VITE_* variables are only available during development, never in production C. Any VITE_* value is embedded verbatim into the shipped JS bundle and is publicly readable by anyone who opens devtools D. Environment variables can only hold numbers, not strings
C. Any VITE_* value is embedded verbatim into the shipped JS bundle and is publicly readable by anyone who opens devtools — Vite inlines every VITE_*-prefixed variable into the built bundle at build time; there is no runtime secrecy on the client, so any value placed there is effectively public regardless of minification.
What is the core difference between XSS and CSRF as attack mechanisms? A. They are two names for the same attack B. XSS runs attacker code inside your origin; CSRF tricks the browser into sending a real authenticated request from a different origin, without running any code in your origin C. CSRF only affects mobile apps, not websites D. XSS requires HTTPS to work; CSRF does not
B. XSS runs attacker code inside your origin; CSRF tricks the browser into sending a real authenticated request from a different origin, without running any code in your origin — XSS is a code-execution vulnerability inside your trust boundary; CSRF exploits the browser's automatic cookie attachment to make a legitimate cross-site-triggered request look like it came from the authenticated user, without any script execution in your origin at all.
Your app currently stores its JWT in localStorage, uses a GET request for the 'delete account' action, and sets Access-Control-Allow-Origin by reflecting the request's Origin header with credentials enabled. List the three fixes, matched to the vulnerability each addresses.
1) Move the JWT to an HttpOnly, Secure, SameSite cookie issued by the server (defends against XSS stealing the token). 2) Change delete-account to a POST/DELETE endpoint requiring a CSRF token, relying on SameSite for baseline protection (defends against CSRF). 3) Replace the reflected-Origin CORS header with an explicit allowlist check (defends against malicious origins reading authenticated responses).
Match each fix to whether it defends against XSS-driven token theft, CSRF, or overly permissive cross-origin reads.