CSRF, cookies & tokens
Protect JavaScript apps by combining SameSite cookies, CSRF tokens, safe token storage, CORS boundaries, and OAuth PKCE flows.
- 01Predict when cookies ride alongCompare origins and sites, then decide when SameSite cookies are sent on state-changing requests.
- 02Choose token storage deliberatelyBalance XSS token theft, CSRF exposure, refresh rotation, in-memory storage, and backend-for-frontend designs.
- 03Separate browser policy from authorizationExplain why CORS controls response reading, while OAuth with PKCE and server checks control access.
Protect sessions and requests
Authentication is not one feature. It is a chain of browser behavior, server checks, token lifetimes, and user redirects. The earlier lessons on cookies, Web Storage, fetch, requests in depth, and CORS taught the pieces. This lesson shows how those pieces fail or protect real sessions.
CSRF protection stops another site from making a signed-in browser perform a state-changing action. Token storage is the choice of where secrets or bearer credentials live. OAuth with PKCE is a redirect-based protocol for getting scoped tokens without putting a client secret in a browser app.
A badge is convenient because you do not type your identity at every door. It is also risky if a courier can make the badge appear at the wrong desk. A signed work order gives the desk a second proof that the action started inside the real office.
- In real life: The badge that opens the office door
- In JavaScript: A cookie-based session ID
- In real life: A courier carrying the badge automatically
- In JavaScript: The browser attaching cookies to matching requests
- In real life: A signed work order for a specific action
- In JavaScript: A CSRF token tied to the session
- In real life: A visitor pass scoped to one room
- In JavaScript: An OAuth access token with audience and scopes
Where the analogy stops: Real guards can look at a person. Servers see requests, headers, cookies, and tokens, so every sensitive action still needs explicit checks.
The attack descriptions here are conceptual and local. They explain why defenses exist; they do not provide working exploits against real sites.
Sites, origins, and sessions
FOUNDATIONAn origin is scheme, host, and port. A site is based on the registrable domain, such as example.co.uk, and modern cookie rules are usually schemeful: https://example.com and http://example.com are not the same site. Real browsers use the Public Suffix List; the helper below uses a tiny hardcoded sample for teaching.
const publicSuffixSample = ["co.uk", "github.io", "com", "org", "net", "dev"]; function registrableDomain(hostname) { const labels = hostname.toLowerCase().split("."); const suffix = publicSuffixSample .sort((a, b) => b.split(".").length - a.split(".").length) .find((item) => hostname === item || hostname.endsWith("." + item)); if (!suffix) return labels.slice(-2).join("."); const suffixLength = suffix.split(".").length; return labels.slice(labels.length - suffixLength - 1).join(".");} function site(url) { const parsed = new URL(url); return parsed.protocol + "//" + registrableDomain(parsed.hostname);} function sameOrigin(a, b) { return new URL(a).origin === new URL(b).origin;} function sameSite(a, b) { return site(a) === site(b);} const page = "https://app.example.co.uk/settings";const api = "https://api.example.co.uk/profile";console.log(sameOrigin(page, api));console.log(sameSite(page, api));console.log(site(api));That distinction matters for sessions. A cookie-based session stores a random session ID in a cookie, often HttpOnly; Secure; SameSite=Lax. The browser sends the cookie when the cookie rules match, and the server looks up the session. A bearer-token API instead expects a header such as Authorization: Bearer .... Whoever holds that token can present it, so storage and lifetime matter.
| Shape | How requests prove identity | Main browser risk |
|---|---|---|
| Cookie session ID | Browser attaches an opaque session cookie automatically. | CSRF unless SameSite and token/header checks stop forged writes. |
| Bearer token | JavaScript or a backend adds an Authorization header. | XSS token theft if the token is readable by page scripts. |
CSRF and SameSite cookies
STEP THROUGHCross-site request forgery depends on one browser feature: matching cookies can be attached to a request even when another site caused the request. A classic example is an auto-submitting form on an attacker-controlled page that posts to a signed-in banking site. The malicious page cannot read the banking response, but the state-changing request might still arrive with the victim's session cookie.
- At risk: state-changing requests authenticated by cookies.
- Lower risk: read-only GET requests. GET must be safe because links, preloads, crawlers, and images can trigger it.
- Different problem: bearer tokens in JavaScript storage are not attached automatically, but XSS can steal or use them.
Step through whether the browser would attach a session cookie to a request.
script
function shouldAttachCookie(cookie, request) { const sameSite = cookie.sameSite ?? "Lax"; const crossSite = request.topLevelSite !== request.requestSite; const safeTopLevelGet = request.topLevelNavigation && request.method === "GET"; if (sameSite === "None" && !cookie.secure) return false; if (!crossSite) return true; if (sameSite === "Strict") return false; if (sameSite === "Lax") return safeTopLevelGet; return true;} topLevelSite: "https://attacker.example", requestSite: "https://bank.example", method: "POST", topLevelNavigation: true,}; console.log(shouldAttachCookie({ sameSite: "Lax", secure: true }, request));console.log(shouldAttachCookie({ sameSite: "None", secure: true }, request));https://attacker.examplehttps://bank.exampleLax blocks cross-site POST forms and subresource requests; do not rely on old Lax+POST grace behavior.
Current major browsers treat a missing SameSite as Lax-like by default, and SameSite=None requires Secure. Chrome once had a temporary “Lax+POST” compatibility grace period for recently set cookies. Do not design around that historical exception: set the attribute you mean and protect writes on the server.
Strict: strongest cross-site blocking, but can break ordinary entry links that need the session immediately.Lax: good default for many first-party apps; cross-site top-level GET links still work.None; Secure: required for legitimate cross-site embedding or federated flows, and it needs additional CSRF defenses.
CSRF tokens and request checks
SERVER CHECKSSameSite is a strong layer, not the whole design. A synchronizer token stores an unpredictable value in the server-side session and renders it into the legitimate page. A double-submit token puts a value in a readable cookie and requires the same value in a form field or header, often signed by the server. Either way, a different site should not be able to read the token and echo it on a forged state-changing request.
// Server-side sketch, not browser JavaScript.if (request.method !== "GET") { assertSameOrigin(request.headers.get("Origin")); assertEquals(request.cookies.csrf, request.body.csrfToken);}performStateChange();Production servers often combine token checks with Origin or Referer validation, Sec-Fetch-Site checks, and method discipline. Custom headers such as X-CSRF-Token also make a cross-origin script request non-simple, so browsers send a preflight before the real request. Treat that as friction, not authorization: the server still validates the token, session, method, and user permission.
JavaScript can write and read normal cookies, as the runnable snippet shows. It cannot create a real HttpOnly cookie; that attribute only works in a server Set-Cookie header. A server-set HttpOnly cookie would be absent from document.cookie.
document.cookie = "cf_visible_token=reader; Max-Age=60; Path=/; SameSite=Lax";const visible = document.cookie.includes("cf_visible_token=reader");console.log(visible);document.cookie = "cf_visible_token=; Max-Age=0; Path=/";Set-Cookie: __Host-session=abc123; Path=/; HttpOnly; Secure; SameSite=LaxSet-Cookie: csrf=public-token; Path=/; Secure; SameSite=LaxStoring tokens safely
TRADE-OFFSBrowser storage is not a vault. The Web Storage lesson showed that localStorage and sessionStorage are readable by any script running on the page. If an XSS bug runs attacker code, that code can copy those tokens or call APIs before you notice.
const localStorage = new Map();localStorage.set("access_token", "eyJhbGciOiJub25l" + "." + "eyJzdWIiOiIxMjMifQ" + "."); function injectedScript(storage) { return storage.get("access_token");} console.log(injectedScript(localStorage).slice(0, 6));console.log("XSS can read JS storage");| Storage | What it protects | What remains risky |
|---|---|---|
localStorage | Survives browser restarts and is readable by every script on the page. | Easy API calls, but an XSS bug can copy the token. Not sent automatically, so CSRF risk is lower. |
sessionStorage | Per top-level tab, reload-safe, and still readable by page scripts. | Limits lifetime to a tab, but XSS in that tab can still steal it. |
| HttpOnly cookie | Server-set cookie hidden from JavaScript and attached by the browser when rules match. | Protects against JS token theft, but cookie-authenticated state changes need CSRF defenses. |
| In-memory token | A variable in the running page, lost on reload. | Harder to steal after the page is gone, but XSS while it is alive can still call APIs as the user. |
| BFF session | Browser holds only an HttpOnly session cookie for your backend-for-frontend. | Keeps OAuth tokens on the server; you still need CSRF defenses and backend authorization. |
There is no silver bullet. Short access-token lifetimes reduce damage. Refresh-token rotation makes each refresh token single-use and lets the server detect replay. In-memory tokens reduce persistence, but a live XSS can still act as the user. A backend-for-frontend keeps OAuth tokens server-side and gives the browser an HttpOnly session cookie, but then you are back to cookie CSRF defenses.
`SameSite=Lax` session cookieSynchronizer CSRF token checked on POST`Origin` and `Sec-Fetch-Site` checks`HttpOnly` session cookie- BFF stores OAuth tokens server-side
- Strict CSP plus output escaping
`Access-Control-Allow-Origin: *`Bearer token in `localStorage`
Sort each defense by the main risk it addresses by itself. Some help both only when combined into a design.
CORS is not access control
SIMULATORCORS answers a narrow browser question: may this page read that cross-origin response? It does not decide whether the server should perform the action. Simple requests, including form-like POST requests, can still reach the server. Non-browser clients such as curl do not enforce CORS. Your API must authenticate, authorize, and validate every request.
Step through what CORS changes: sending the request versus exposing the response.
script
function decideCors(request, response) { const simpleMethod = ["GET", "HEAD", "POST"].includes(request.method); const simpleContentType = ["text/plain", "multipart/form-data", "application/x-www-form-urlencoded"] .includes(request.headers["Content-Type"]); const simple = simpleMethod && simpleContentType; const actualRequestSent = simple || response["Access-Control-Allow-Methods"]?.includes(request.method); const readable = response["Access-Control-Allow-Origin"] === request.origin && (request.credentials !== "include" || response["Access-Control-Allow-Credentials"] === "true"); return { simple, actualRequestSent, readable };} origin: "https://app.example", method: "POST", headers: { "Content-Type": "text/plain" }, credentials: "include",};const response = {};const result = decideCors(request, response);console.log(result.simple);console.log(result.actualRequestSent);console.log(result.readable);function decideCors(request, response) { const simpleMethod = ["GET", "HEAD", "POST"].includes(request.method); const simpleContentType = ["text/plain", "multipart/form-data", "application/x-www-form-urlencoded"] .includes(request.headers["Content-Type"]); const simple = simpleMethod && simpleContentType; const actualRequestSent = simple || response["Access-Control-Allow-Methods"]?.includes(request.method); const readable = response["Access-Control-Allow-Origin"] === request.origin && (request.credentials !== "include" || response["Access-Control-Allow-Credentials"] === "true"); return { simple, actualRequestSent, readable };} const request = { origin: "https://app.example", method: "POST", headers: { "Content-Type": "text/plain" }, credentials: "include",};const response = {};const result = decideCors(request, response);console.log(result.simple);console.log(result.actualRequestSent);console.log(result.readable);sentnoSimple. Actual request sent. Page read blocked. The request can reach the server, but the response has no Access-Control-Allow-Origin.
Credentialed browser reads are stricter: Access-Control-Allow-Origin: * cannot be combined with credentials. The server must echo the exact allowed origin and send Access-Control-Allow-Credentials: true. If it dynamically echoes origins, it should also send Vary: Origin so shared caches do not mix responses.
OAuth and PKCE basics
REAL HASHOAuth separates the person, the application, the login/token issuer, and the API. A browser SPA is a public client because it cannot keep a client secret. The modern pattern is authorization code flow with PKCE: the app creates a high-entropy code_verifier, sends a derived code_challenge during the redirect, then proves the verifier when exchanging the authorization code.
| Role | In this lesson |
|---|---|
| Resource owner | The person who owns the data or grants access. |
| Client | The application asking for access. A browser SPA is a public client; a BFF can keep secrets server-side. |
| Authorization server | Authenticates the user and issues authorization codes and tokens. |
| Resource server | The API that accepts access tokens and enforces scopes, audience, and authorization. |
Resource owner: the person signing in
Client: the JavaScript app or its backend-for-frontend
Authorization server: issues codes and tokens
Resource server: the API that accepts access tokens
Flow for SPAs: authorization code + PKCE + state, not implicitfunction base64url(bytes) { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).split("+").join("-").split("/").join("_").replace(/=+$/g, "");} async function pkceChallengeFromVerifier(verifier) { const bytes = new TextEncoder().encode(verifier); const digest = await crypto.subtle.digest("SHA-256", bytes); return base64url(new Uint8Array(digest));} (async () => { const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; const challenge = await pkceChallengeFromVerifier(verifier); console.log(challenge); console.log(challenge === "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");})();calculating...Changing the verifier changes the SHA-256 based code challenge.
The state parameter is separate from PKCE. It binds the authorization response to the browser flow that started it, protecting the redirect from CSRF-style mix-ups. Access tokens go to resource servers. Refresh tokens get new access tokens and should be rotated or sender-bound. ID tokens are OpenID Connect identity assertions for the client, not API authorization tokens. OAuth security guidance deprecates the implicit flow; OAuth 2.1 drafts remove it in favor of authorization code with PKCE.
JWTs and OpenID Connect
DECODE CAREFULLYMany OAuth and OpenID Connect deployments use JWTs. A JWT has base64url-encoded parts separated by dots: header, payload, and signature. JavaScript can decode the first two parts to inspect JSON, using the same base64url ideas from Text encoding. That is not verification. Verification checks the signature, issuer, audience, expiration, and other claims with trusted keys.
function base64UrlToText(part) { const base64 = part.replace(/-/g, "+").replace(/_/g, "/") .padEnd(Math.ceil(part.length / 4) * 4, "="); const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); return new TextDecoder().decode(bytes);} const token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoicmVhZGVyIn0.signature";const [headerPart, payloadPart] = token.split(".");const header = JSON.parse(base64UrlToText(headerPart));const payload = JSON.parse(base64UrlToText(payloadPart)); console.log(header.alg);console.log(payload.sub);console.log("read, not verified");OpenID Connect adds an identity layer on top of OAuth. It defines ID tokens, a userinfo endpoint, discovery metadata, and standard scopes such as openid, profile, and email. Use an ID token to learn who signed in to your client; use access tokens for API authorization.
Common misconceptions
- “CORS stops CSRF.” It does not. CORS controls browser reads, while CSRF is about forged writes.
- “SameSite means I do not need tokens.” SameSite is a layer. Sensitive writes still deserve server checks.
- “HttpOnly cookies are always safer.” They hide values from JavaScript, but they still ride with requests.
- “localStorage is safe because it is same-origin.” XSS runs in that origin and can read it.
- “JWTs are secure because they look encoded.” Base64url is reversible. Trust comes from verification and claims.
- “PKCE replaces the state parameter.” PKCE protects code exchange; state protects the redirect flow.
| Idea | What it does | What it does not do |
|---|---|---|
| CORS | Controls whether browser JavaScript may read a cross-origin response. | It is not authorization and does not stop simple requests or curl. |
| SameSite | Controls whether the browser attaches a cookie in cross-site situations. | It helps CSRF but does not replace tokens and server checks. |
| HttpOnly | Keeps JavaScript from reading a cookie value. | It does not stop the browser from sending the cookie. |
| JWT decoding | Base64url-decoding header and payload text. | Reading a JWT is not signature verification. |
Practice exercises
5 EXERCISESPredict whether the cookie is sent.
const topLevelSite = "https://attacker.example";
const requestSite = "https://bank.example";
const method = "POST";
const sameSite = "Lax";
const sent = topLevelSite === requestSite || (sameSite === "Lax" && method === "GET");
console.log(sent ? "sent" : "blocked");const topLevelSite = "https://attacker.example";
const requestSite = "https://bank.example";
const method = "POST";
const sameSite = "Lax";
const sent = topLevelSite === requestSite || (sameSite === "Lax" && method === "GET");
console.log(sent ? "sent" : "blocked");The simplified decision prints blocked because the request is cross-site and state-changing.
What two words print?
const simple = true;
const serverReceivedRequest = simple;
const responseHasAllowOrigin = false;
console.log(serverReceivedRequest ? "sent" : "not sent");
console.log(responseHasAllowOrigin ? "readable" : "blocked");const simple = true;
const serverReceivedRequest = simple;
const responseHasAllowOrigin = false;
console.log(serverReceivedRequest ? "sent" : "not sent");
console.log(responseHasAllowOrigin ? "readable" : "blocked");The server receives the simple request, but browser JavaScript cannot read the response, so the words are sent and blocked.
An app keeps an access token where every script on the page can read it. Which browser storage API is the warning about?
Both localStorage and sessionStorage are JavaScript-readable. Do not store long-lived bearer or refresh tokens there.
What role prints?
const payload = JSON.parse(new TextDecoder().decode(
Uint8Array.from(atob("eyJzdWIiOiIxMjMiLCJyb2xlIjoicmVhZGVyIn0"), (char) => char.charCodeAt(0))
));
console.log(payload.role);const payload = JSON.parse(new TextDecoder().decode(
Uint8Array.from(atob("eyJzdWIiOiIxMjMiLCJyb2xlIjoicmVhZGVyIn0"), (char) => char.charCodeAt(0))
));
console.log(payload.role);The payload role is reader. That only reads text; a server still must verify the JWT.
Your SPA still uses implicit flow. Which OAuth flow should replace it?
Use authorization code with PKCE and a state parameter. Then choose token storage deliberately, often a BFF or short-lived in-memory access token plus rotated refresh token depending on the architecture.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which request is at highest CSRF risk?
Choose an answer to see the explanation.
Question 2 of 8What does this SameSite prediction print?
Read the code, then predictconst topLevelSite = "https://attacker.example"; const requestSite = "https://bank.example"; const method = "POST"; const sameSite = "Lax"; const sent = topLevelSite === requestSite || (sameSite === "Lax" && method === "GET"); console.log(sent ? "sent" : "blocked");Choose an answer to see the explanation.
Question 3 of 8Which statement about
HttpOnlyis true?Choose an answer to see the explanation.
Question 4 of 8What does the CORS model print?
Read the code, then predictconst simple = true; const serverReceivedRequest = simple; const responseHasAllowOrigin = false; console.log(serverReceivedRequest ? "sent" : "not sent"); console.log(responseHasAllowOrigin ? "readable" : "blocked");Choose an answer to see the explanation.
Question 5 of 8Why is
Access-Control-Allow-Origin: *invalid with credentials?Choose an answer to see the explanation.
Question 6 of 8What does this JWT payload decode print?
Read the code, then predictconst payload = JSON.parse(new TextDecoder().decode( Uint8Array.from(atob("eyJzdWIiOiIxMjMiLCJyb2xlIjoicmVhZGVyIn0"), (char) => char.charCodeAt(0)) )); console.log(payload.role);Choose an answer to see the explanation.
Question 7 of 8What is the modern OAuth flow for a browser SPA?
Choose an answer to see the explanation.
Question 8 of 8Which storage choice best limits JavaScript token theft?
Choose an answer to see the explanation.
Key takeaways
- CSRF targets state-changing, cookie-authenticated requests because browsers attach matching cookies automatically.
- Use safe GETs, explicit SameSite cookies, CSRF tokens, Origin/Sec-Fetch-Site checks, and authorization on every write.
- Web Storage is script-readable; an XSS bug can steal localStorage or sessionStorage tokens.
- HttpOnly cookies reduce JavaScript token theft but require CSRF defenses.
- CORS controls whether browser JavaScript can read a response; it is not server-side access control.
- SPAs should use authorization code with PKCE and state; decoding a JWT is not verifying it.
Final definition: secure browser authentication is a layered design: choose how credentials travel, prevent forged writes, keep tokens out of script-readable storage when possible, and verify every token or session server-side.
Up next: prototype pollution and unsafe object merges.