cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

CSRF, cookies & tokens

Protect JavaScript apps by combining SameSite cookies, CSRF tokens, safe token storage, CORS boundaries, and OAuth PKCE flows.

By the end, you can
  • 01
    Predict when cookies ride alongCompare origins and sites, then decide when SameSite cookies are sent on state-changing requests.
  • 02
    Choose token storage deliberatelyBalance XSS token theft, CSRF exposure, refresh rotation, in-memory storage, and backend-for-frontend designs.
  • 03
    Separate 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.

Definition

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.

Real-life analogyA building badge and a signed work order

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.

Defensive scope

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

FOUNDATION

An 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.

Same origin versus same sitePop out in the code editor (opens in a new tab)JavaScript
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.

Two common session shapes
ShapeHow requests prove identityMain browser risk
Cookie session IDBrowser attaches an opaque session cookie automatically.CSRF unless SameSite and token/header checks stop forged writes.
Bearer tokenJavaScript or a backend adds an Authorization header.XSS token theft if the token is readable by page scripts.

CSRF and SameSite cookies

STEP THROUGH

Cross-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 SameSite cookie attachment
Step 0 of 4Ready
Your turn: follow the blue line

Step through whether the browser would attach a session cookie to a request.

Running in
  1. script
Next: line 13
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Request to replay
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
SameSite cookie simulator
Same origin?no
Same site?no
Top-level sitehttps://attacker.example
Request sitehttps://bank.example
Cookieblocked
Try it yourself

Lax blocks cross-site POST forms and subresource requests; do not rely on old Lax+POST grace behavior.

Teaching simplification: site uses scheme plus registrable domain from a tiny public-suffix sample. Browsers use the full Public Suffix List.

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.

Choosing SameSite
  • 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 CHECKS

SameSite 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 CSRF token sketchJavaScript
// 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.

HttpOnly proof, honestly stated

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.

A JavaScript-visible cookiePop out in the code editor (opens in a new tab)JavaScript
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=/";
Server Set-Cookie headersHTTP
Set-Cookie: __Host-session=abc123; Path=/; HttpOnly; Secure; SameSite=LaxSet-Cookie: csrf=public-token; Path=/; Secure; SameSite=Lax

Storing tokens safely

TRADE-OFFS

Browser 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.

Why JavaScript-readable storage is riskyPop out in the code editor (opens in a new tab)JavaScript
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");
Token storage trade-offs
StorageWhat it protectsWhat remains risky
localStorageSurvives 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.
sessionStoragePer 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 cookieServer-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 tokenA 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 sessionBrowser 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.

Which defense helps which risk?
  • `SameSite=Lax` session cookie
  • Synchronizer 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`
Try it yourself
0 of 8 correct

Sort each defense by the main risk it addresses by itself. Some help both only when combined into a design.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

CORS is not access control

SIMULATOR

CORS 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 the CORS read decision
Step 0 of 4Ready
Your turn: follow the blue line

Step through what CORS changes: sending the request versus exposing the response.

Running in
  1. script
Next: line 14
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
CORS case
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
CORS read simulator
CORS decision modelPop out in the code editor (opens in a new tab)JavaScript
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);
Browser resultblocked
Request shapesimple
Actual requestsent
Page can readno
Try it yourself

Simple. Actual request sent. Page read blocked. The request can reach the server, but the response has no Access-Control-Allow-Origin.

This is a browser CORS model, not an authorization system. A server must still authenticate and authorize every request.

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 HASH

OAuth 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.

OAuth roles
RoleIn this lesson
Resource ownerThe person who owns the data or grants access.
ClientThe application asking for access. A browser SPA is a public client; a BFF can keep secrets server-side.
Authorization serverAuthenticates the user and issues authorization codes and tokens.
Resource serverThe API that accepts access tokens and enforces scopes, audience, and authorization.
OAuth flow summaryText
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 implicit
PKCE challenge workbench
PKCE S256 challengePop out in the code editor (opens in a new tab)JavaScript
function 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");})();
ChallengeS256
code_challengecalculating...
RFC vector?no
Try it yourself

Changing the verifier changes the SHA-256 based code challenge.

The browser computes SHA-256 with crypto.subtle.digest, then base64url-encodes the bytes. No secret leaves the page.

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 CAREFULLY

Many 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.

Decode JWT text without verifying itPop out in the code editor (opens in a new tab)JavaScript
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 in one paragraph

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.
Similar ideas that are easy to confuse
IdeaWhat it doesWhat it does not do
CORSControls whether browser JavaScript may read a cross-origin response.It is not authorization and does not stop simple requests or curl.
SameSiteControls whether the browser attaches a cookie in cross-site situations.It helps CSRF but does not replace tokens and server checks.
HttpOnlyKeeps JavaScript from reading a cookie value.It does not stop the browser from sending the cookie.
JWT decodingBase64url-decoding header and payload text.Reading a JWT is not signature verification.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict SameSite=Lax

Predict whether the cookie is sent.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeCORS request versus read

    What two words print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const simple = true;
    const serverReceivedRequest = simple;
    const responseHasAllowOrigin = false;
    console.log(serverReceivedRequest ? "sent" : "not sent");
    console.log(responseHasAllowOrigin ? "readable" : "blocked");

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the token storage bug

      An app keeps an access token where every script on the page can read it. Which browser storage API is the warning about?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeDecode without trusting

        What role prints?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const payload = JSON.parse(new TextDecoder().decode(
          Uint8Array.from(atob("eyJzdWIiOiIxMjMiLCJyb2xlIjoicmVhZGVyIn0"), (char) => char.charCodeAt(0))
        ));
        console.log(payload.role);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeDesign a safer SPA sign-in

          Your SPA still uses implicit flow. Which OAuth flow should replace it?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            CSRF, cookies & tokens quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which request is at highest CSRF risk?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this SameSite prediction print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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");

              Choose an answer to see the explanation.

            3. Question 3 of 8Which statement about HttpOnly is true?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does the CORS model print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            5. Question 5 of 8Why is Access-Control-Allow-Origin: * invalid with credentials?

              Choose an answer to see the explanation.

            6. Question 6 of 8What does this JWT payload decode print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            7. Question 7 of 8What is the modern OAuth flow for a browser SPA?

              Choose an answer to see the explanation.

            8. 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.

            CompleteFrontend Clear concepts. Working examples.