CSRF, cookies & tokens
Protect JavaScript apps by combining SameSite cookies, CSRF tokens, safe token storage, CORS boundaries, and OAuth PKCE flows.
- 01Predict when the browser sends a cookieTell origins and sites apart, then say whether a SameSite cookie goes with a request that changes something.
- 02Choose where tokens liveWeigh scripts stealing tokens against fake requests from other sites, and know when to use refresh tokens, a JavaScript variable, or a backend-for-frontend.
- 03Keep browser rules and permission checks apartExplain why CORS only decides whether a page may read a reply, while OAuth with PKCE and server checks decide who may act.
Protect sessions and requests
When you sign in to a website, the site must know it is still you on every later request. A request is one message from the browser to a server, such as "show my account" or "move $50". This lesson shows how to keep those requests safe.
It uses ideas from earlier lessons: cookies, Web Storage, fetch, requests in depth, and CORS. Here you put them together to protect a signed-in user.
- CSRF protection stops another site from making a signed-in browser do something the user didn't ask for, such as moving money or changing a password. CSRF stands for cross-site request forgery: a fake request started from another site.
- Token storage is the choice of where the browser keeps a token. A token is a secret string that proves who you are.
- OAuth with PKCE is a safe way for a browser app to get tokens through a sign-in page, without keeping a secret password inside the app.
At McDonald's, you get a token number with your order. The screen shows your number when the food is ready. The counter only hands the food to someone with that number. A stranger who doesn't know your number can't collect your order.
- In real life: Your order's token number
- In JavaScript: A CSRF token that belongs to your session
- In real life: The counter checks the number
- In JavaScript: The server checks the token the request sends
- In real life: A stranger doesn't know your number
- In JavaScript: Another site can't read the token on your page
The attack examples here are small models that run on this page. They show why each defense exists. They don't attack any real site.
Sites, origins, and sessions
FOUNDATIONBrowsers group web addresses in two ways:
- An origin is three parts of a URL: the scheme (
https), the host (app.example.co.uk), and the port (443 for https unless the URL says another). Two URLs have the same origin only when all three parts match. - A site is wider. It is the scheme plus the registrable domain: the part of the host a person can buy, such as
example.co.uk. Soapp.example.co.ukandapi.example.co.ukare the same site.
The scheme counts for sites too: http://example.com and https://example.com are different sites. To find the registrable domain, browsers use the Public Suffix List, a long list of endings such as .com and .co.uk. The code below uses a tiny sample of that list so you can read it.
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));Lines 3-11 find the registrable domain from the sample list. Lines 13-16 build the site: the scheme plus that domain. Line 18 compares origins, and line 22 compares sites.
- Line 28 prints
false: the page and the API have different hosts, so they are not the same origin. - Line 29 prints
true: both end inexample.co.uk, so they are the same site. - Line 30 prints the API's site,
https://example.co.uk.
Now, sessions. A session is how a server remembers that you signed in. There are two common kinds:
- A cookie session: the server gives the browser a random session ID in a cookie, often with
HttpOnly; Secure; SameSite=Lax. The browser then adds that cookie to matching requests by itself. The server looks up the ID to find out who you are. - A bearer token: the app adds a header such as
Authorization: Bearer ...to each request. "Bearer" means whoever holds the token can use it, like cash. So where you keep it, and how long it lasts, matter.
Both kinds face a risk called XSS (cross-site scripting): an attacker's script running inside your page. The table compares the two kinds. Each row is one kind of session.
| Kind of session | How a request proves who you are | The main risk in the browser |
|---|---|---|
| Cookie session ID | The browser adds the session cookie to matching requests by itself. | CSRF: another site can make the browser send the cookie. SameSite and token checks stop that. |
| Bearer token | JavaScript or a server adds an Authorization header to each request. | XSS: if page scripts can read the token, an injected script can steal it. |
CSRF and SameSite cookies
STEP THROUGHA CSRF attack uses one browser habit. The browser adds matching cookies to a request, even when another site started the request.
Here is the classic example. A page on another site has a hidden form. The form sends itself to a bank where you are signed in. That page can't read the bank's reply. But the request may still reach the bank with your session cookie, and the bank may move the money.
Which requests are at risk?
- At risk: requests that change something, such as moving money, when the server knows you by a cookie.
- Lower risk:
GETrequests that only read. KeepGETsafe: it should never change anything, because links, preloading, search crawlers, and images can all send aGET. - A different problem: the browser never adds a bearer token from JavaScript storage by itself. But an XSS script can steal the token or use it.
A cookie's SameSite attribute tells the browser when to send it with a request that another site started. If a cookie has no SameSite, browsers today treat it as Lax.
const cookie = {};const effectiveSameSite = cookie.sameSite ?? "Lax";console.log(effectiveSameSite);Line 1 models a cookie with no sameSite value. Line 2 uses ??, which picks the right side when the left side is null or undefined. So it picks "Lax". Line 3 prints Lax. In real apps, always write the attribute, so anyone reading the code sees the rule you chose.
The step-through below runs a request from attacker.example to bank.example. The picture shows its two results.
POST to the bank. A SameSite=Lax cookie is not sent with it. A SameSite=None cookie is sent, so the bank needs other checks.Step through whether the browser sends a session cookie with 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 doesn't send the cookie with a POST form, image, or frame from another site. Don't count on Chrome's old Lax+POST exception.
Today's major browsers treat a missing SameSite as Lax. A cookie with SameSite=None must also have Secure, which means the browser only sends it over HTTPS.
Chrome once had a short exception called "Lax+POST". For two minutes after a cookie with no SameSite was set, Chrome still sent it with a form POST from another site. Don't build on that old exception. Write the attribute you mean, and check every write on the server.
Strict: never send the cookie on a request another site started. That includes a link on another site that opens your app, so the user may look signed out on that first page.Lax: send the cookie when the user follows a link from another site to your page. That is aGETthat changes the address bar. Don't send it on cross-sitePOSTrequests, or for images and frames on other sites.None; Secure: send the cookie on cross-site requests too, for embeds and partner sites. Keep CSRF checks. Browsers may still block it under their rules for third-party cookies.
CSRF tokens and request checks
SERVER CHECKSSameSite is one strong layer, not the whole defense. The next layer is a CSRF token.
A synchronizer token is a random value the server saves with your session. The server also puts it into its own forms and pages. When a request wants to change something, the server checks that the request carries the same value. Another site can't read your page, so it can't copy the token.
function tokenMatches(sessionToken, submittedToken) { return typeof submittedToken === "string" && sessionToken === submittedToken;}const sessionToken = "random-session-token";console.log(tokenMatches(sessionToken, "random-session-token"));console.log(tokenMatches(sessionToken, ""));- Lines 1-3 define
tokenMatches. It givestrueonly when the sent token is a string and equals the session's token. - Line 4 is the token saved in the session.
- Line 5 sends the same token and prints
true. - Line 6 sends an empty string and prints
false.
tokenMatches gives true only when the request sends that same token.This is a small model. Real tokens must be random, belong to one session, and be checked on the server.
A second design is the double-submit cookie. The server puts a CSRF value in a cookie, and the page repeats it in a form field or a header. Checking that the two are equal is not enough. A sibling subdomain, such as blog.example.com next to app.example.com, may be able to set a matching cookie. So sign the token and tie it to the session.
Where you can, make the CSRF cookie host-only: leave out the Domain attribute, so only your exact host gets it. Neither token design stops XSS. A script running inside your page can read the token and use it.
// Server-side sketch, not browser JavaScript.if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) { assertSameOrigin(request.headers.get("Origin")); assertEquals(request.session.csrfToken, request.body.csrfToken); performStateChange();} Line 1 says this is server code, not browser code. Line 2 picks the methods that change things: POST, PUT, PATCH, and DELETE. Line 3 checks the Origin header, and line 4 checks the token. Only then does line 5 make the change.
The server can also check where a request came from. Browsers add an Origin header to most requests that change things. It holds the origin of the page that sent the request. When Origin is missing, the server may use the origin from the Referer header instead. Referer holds the URL of the page that sent the request.
- Compare the origin with an exact list of allowed origins.
- Parse the URL first, then compare scheme, host, and port.
- Never accept a match on just the start or end of the text.
https://app.example.evil.testalso starts withhttps://app.example. - If both headers are missing or broken, reject the write.
const allowedOrigin = "https://app.example";function matches(origin, referer) { const value = origin || referer; if (!value) return false; try { return new URL(value).origin === allowedOrigin; } catch { return false; }}console.log(matches("https://app.example", ""));console.log(matches("", "https://evil.example/form"));console.log(matches("", ""));console.log(matches("", "https://app.example/form"));console.log(matches("not a URL", "https://app.example/form"));Line 3 uses origin, or referer when origin is empty. Line 4 rejects a request with neither. Line 5 parses the URL and compares its origin with the allowed one. If parsing fails, it gives false. The five calls print:
- Line 7:
true. TheOriginis the app. - Line 8:
false. TheRefereris from another site. - Line 9:
false. Both headers are missing. - Line 10:
true.Originis missing, and theRefereris from the app. - Line 11:
false. TheOriginis not a valid URL. When in doubt, the check says no.
Some privacy settings remove Referer details. So decide on purpose what happens when neither header can be checked. Don't allow the write by default.
A JSON API can add one more check: a custom header such as X-CSRF-Token. A normal HTML form can't set custom headers. And when a page on another origin uses fetch with a custom header, or with the content type application/json, the browser first sends a preflight. A preflight is a quick OPTIONS request that asks the server, "May I send this?"
Keep the server's list of allowed origins strict, and reject form-style content types on these endpoints. A preflight is an extra browser check. It doesn't decide whether the user may do the action.
const token = "session-token";const form = { contentType: "application/x-www-form-urlencoded", headers: {} };const app = { contentType: "application/json", headers: { "X-CSRF-Token": token } };const accepts = (request) => request.contentType === "application/json" && request.headers["X-CSRF-Token"] === token;console.log(accepts(form));console.log(accepts(app));Line 4 accepts a request only when it is JSON and carries the right token. The form request on line 2 has neither, so line 5 prints false. The app request on line 3 has both, so line 6 prints true. A real server must also check the session, and whether this user may do this action.
JavaScript can write and read normal cookies, as the code below shows. It can't make a real HttpOnly cookie. Only a server can, with a Set-Cookie header. A server-set HttpOnly cookie never shows up in 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=/";Line 1 writes a cookie named cf_visible_token that lasts 60 seconds. Line 2 checks whether document.cookie contains it. Line 3 prints true, because this cookie is not HttpOnly. Line 4 deletes it by setting Max-Age=0.
Set-Cookie: __Host-session=abc123; Path=/; HttpOnly; Secure; SameSite=LaxSet-Cookie: csrf=public-token; Path=/; Secure; SameSite=LaxLine 1 is the session cookie: HttpOnly, Secure, and SameSite=Lax. The __Host- name prefix makes the browser accept it only with Secure, Path=/, and no Domain, so it belongs to one exact host. Line 2 is a CSRF cookie without HttpOnly, so your own page script can read it and copy it into a header.
Storing tokens safely
CHOICESBrowser storage is not a safe. The Web Storage lesson showed that any script on the page can read localStorage and sessionStorage. If an XSS bug lets an attacker's script run, that script can copy tokens from there, or call your APIs with them.
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");- Line 1 uses a
Mapto stand in forlocalStorage, so the demo stays inside this page. - Line 2 saves a sample token under the name
access_token. - Lines 4-6 stand for a script an attacker slipped into the page. It only needs one line to read the token.
- Line 8 prints the first six characters,
eyJhbG. Line 9 prints the point of the demo.
localStorage, including a script an attacker slipped in. The browser hides an HttpOnly cookie from scripts, but still sends it with requests.The table compares five places to keep a token. Each row says what that place protects and what risk is left.
| Where the token lives | What it protects | What is still risky |
|---|---|---|
localStorage | It stays after the browser restarts. The browser never sends it by itself, so the CSRF risk is low. | Any script on the page can read it, so an XSS bug can copy the token. |
sessionStorage | It lasts only as long as the tab. A reload keeps it. | Scripts on the page can still read it, so XSS in that tab can steal the token. |
HttpOnly cookie | The server sets it, and page scripts can't read it. The browser sends it when the cookie rules match. | The browser sends it by itself, so requests that change things need CSRF defenses. |
| A JavaScript variable (in memory) | It is gone after a reload, so there is nothing left to steal later. | While the page is open, an XSS script can still call your APIs with it. |
| BFF session | The browser only holds an HttpOnly session cookie. Your backend-for-frontend keeps the OAuth tokens. | The cookie still needs CSRF defenses, and the BFF must check what each user may do. |
Many tokens are JWTs (JSON Web Tokens). The last section explains them. A JWT holds claims: named fields such as who the user is. The exp claim says when the token expires. It is a Unix timestamp in seconds: the number of seconds since 1 January 1970.
The server must check the token's signature and its exp. A countdown in the page is only a hint for the user.
const nowSeconds = 1_800_000_000;const expSeconds = nowSeconds + 300;const secondsRemaining = expSeconds - nowSeconds;console.log(secondsRemaining);console.log(expSeconds <= nowSeconds);Line 1 sets a sample clock. Line 2 sets the expiry 300 seconds later. Line 3 subtracts to get the time left. Line 4 prints 300. Line 5 prints false: the token has not expired yet. These numbers are made up; this is not a real token.
Two kinds of token often work together:
- An access token is sent to the API. Give it a short life, so a stolen copy stops working soon. A short life doesn't stop theft. It only limits how long a stolen token works.
- A refresh token gets a new access token when the old one expires. Rotate it: after each use, hand out a new one and cancel the old one. If someone uses an old one again, it was probably stolen, so cancel every refresh token in that chain. Give refresh sessions an end date too, then ask the user to sign in again.
HttpOnly stops page scripts from reading a cookie. It doesn't stop an XSS script from sending requests from your page, and the browser adds the cookie to those. So keep CSRF defenses and permission checks.
A token kept only in a JavaScript variable is called in memory. An XSS script can still use it while the page is open. A backend-for-frontend (BFF) is a small server of your own that keeps the OAuth tokens. The browser only gets a session cookie for the BFF, and that cookie still needs CSRF defenses.
`SameSite=Lax` session cookieCSRF token checked on every `POST``Origin` and `Sec-Fetch-Site` checks`HttpOnly` session cookie- A BFF keeps OAuth tokens on its server
- Strict CSP plus output escaping
`Access-Control-Allow-Origin: *`Bearer token in `localStorage`
Sort each defense by the main risk it handles on its own. Some defenses only help with both when you combine them.
CORS doesn't decide who may act
TRY ITCORS (Cross-Origin Resource Sharing) answers one small browser question: may this page read the reply from another origin? It doesn't decide whether the server should do the action.
A simple request is one a normal HTML form could send: GET, HEAD, or POST, with only basic headers. The browser sends it with no preflight, so it reaches the server. Tools outside the browser, such as curl, ignore CORS completely. So your API must check every request: who sent it, whether they may do this, and whether the data is valid.
The picture shows the first case in the step-through below: a simple POST, and a reply with no CORS headers.
POST still reaches the server. Without CORS headers on the reply, the browser only stops the page from reading it.Step through the two things CORS keeps apart: sending the request, and letting the page read the reply.
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 request, no preflight. Real request sent. Page can't read the reply. The request reaches the server, but the reply has no Access-Control-Allow-Origin, so the page can't read it.
Reading a reply that was sent with cookies has stricter rules. Access-Control-Allow-Origin: * can't be used with credentials (cookies or HTTP sign-in). The server must name the exact allowed origin and also send Access-Control-Allow-Credentials: true.
If the server copies the request's origin into that header, it should also send Vary: Origin. That tells shared caches to keep a separate copy of the reply for each origin.
OAuth and PKCE basics
REAL HASHOAuth is a standard way for an app to get permission to use an API for a user. It has four roles. Each row of the table names one role and who plays it.
| Role | Who it is in this lesson |
|---|---|
| Resource owner | The person who owns the data and says yes to sharing it. |
| Client | The app asking for access. A browser SPA is a public client, because it can't keep a secret. A BFF can keep secrets on its server. |
| Authorization server | It checks who the user is, then hands out authorization codes and tokens. |
| Resource server | The API. It accepts access tokens and checks what each token allows. |
A browser single-page app (SPA) is a public client: it can't keep a client secret, because anyone can read its code. So SPAs use the authorization code flow with PKCE (Proof Key for Code Exchange):
- The app makes a long random string, the
code_verifier, and keeps it. - It hashes the verifier into a
code_challengeand sends that with the sign-in redirect. - After the user signs in, the app gets a one-time authorization code.
- The app swaps the code for tokens and sends the verifier along. The server hashes the verifier and checks that it matches the challenge.
A hash is a one-way scramble: easy to compute, but you can't get the verifier back from the challenge. So someone who steals the code can't swap it for tokens without the verifier.
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...You changed the verifier, so its SHA-256 hash, the code challenge, changed too.
The state parameter is separate from PKCE. It is a random value the app sends with the redirect and checks when the user comes back. It proves the reply belongs to the sign-in this browser started. That stops CSRF-style mix-ups.
Three kinds of token come out of OAuth:
- Access tokens go to the API, which OAuth calls the resource server.
- Refresh tokens get new access tokens. Rotate them, or bind them to the app that got them, so no one else can use them.
- ID tokens come from OpenID Connect. They tell your app who signed in. They are not for calling APIs.
OAuth security advice no longer recommends the old implicit flow, which put tokens straight into the URL. OAuth 2.1 drafts remove it. Use the authorization code flow with PKCE instead.
JWTs and OpenID Connect
DECODE CAREFULLYMany OAuth and OpenID Connect systems use JWTs. A JWT is three parts joined by dots:
- The header says how the token was signed, such as
RS256. - The payload holds the claims, such as
sub, the user's ID. - The signature proves who made the token and that nobody changed it.
The header and payload are base64url text: JSON turned into URL-safe letters, as in the Text encoding lesson. Anyone can decode them. Decoding is not verifying.
Verifying checks the signature with a trusted key. It also checks claims such as who issued the token (iss), who it is for (aud), and when it expires (exp).
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");- Lines 2-3 turn base64url back into normal base64 and add
=padding at the end. - Line 4 turns that into bytes, and line 5 turns the bytes into text.
- Line 9 splits the token at the dots. Lines 10-11 read the header and the payload as JSON.
- The code prints
RS256, then123, thenread, not verified. Nothing here checked the signature.
OpenID Connect (OIDC) adds sign-in on top of OAuth. It defines:
- ID tokens, which say who signed in.
- A userinfo endpoint, a URL that returns details about the user.
- Discovery metadata, a public file that lists the server's settings.
- Standard scopes (named permissions) such as
openid,profile, andemail.
Use an ID token to learn who signed in to your app. Use access tokens to call APIs.
Common misconceptions
- "CORS stops CSRF." It doesn't. CORS decides whether a page may read a reply. CSRF is about fake requests that change things.
- "With SameSite, I don't need tokens." SameSite is one layer. Important writes still need checks on the server.
- "HttpOnly cookies are always safer." They hide the value from scripts, but the browser still sends them with requests.
- "localStorage is safe because only my origin can read it." An XSS script runs in your origin, so it can read it too.
- "JWTs are secure because they look scrambled." Anyone can decode base64url. Trust comes from checking the signature and claims.
- "PKCE replaces the state parameter." PKCE protects the code swap.
stateprotects the redirect back to your app.
The table puts four of these ideas side by side. Each row says what the idea does and what it doesn't do.
| Idea | What it does | What it doesn't do |
|---|---|---|
| CORS | It decides whether a page may read a reply from another origin. | It doesn't decide who may act. Simple requests and curl still reach the server. |
| SameSite | It decides whether the browser sends a cookie with a request another site started. | It helps against CSRF, but it doesn't replace tokens and server checks. |
| HttpOnly | It stops page scripts from reading a cookie's value. | It doesn't stop the browser from sending the cookie. |
| JWT decoding | It turns the header and payload from base64url back into text. | It doesn't check the signature, so it proves nothing. |
Practice exercises
5 EXERCISESDoes the browser send this Lax cookie? Predict what the program prints.
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 sites are different and the method is POST, so the program prints blocked.
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 gets the simple request, so the first word is sent. The page can't read the reply, so the second word is blocked.
An app keeps an access token where every script on the page can read it. Which browser storage is the problem?
Both localStorage and sessionStorage can be read by any script on the page. Don't keep long-lived bearer tokens 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's role is reader. This only reads the text; a server must still verify the JWT before trusting it.
Your SPA still uses the implicit flow. Which OAuth flow should replace it?
Use the authorization code flow with PKCE, plus a state value. Then choose where tokens live on purpose: often a BFF, or a short-lived access token in memory with a rotated refresh token.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which request is most at risk from CSRF?
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 can't
Access-Control-Allow-Origin: *be used 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 8Which OAuth flow should a browser SPA use today?
Choose an answer to see the explanation.
Question 8 of 8Which choice best stops scripts from stealing tokens?
Choose an answer to see the explanation.
Key takeaways
- CSRF targets requests that change something and use a cookie to know the user, because the browser adds matching cookies by itself.
- Keep
GETsafe. SetSameSiteon purpose. Check a CSRF token and theOriginheader on every write, and check the user may do it. - Any script on the page can read Web Storage, so an XSS bug can steal tokens from
localStorageorsessionStorage. HttpOnlycookies keep tokens away from scripts, but they still need CSRF defenses.- CORS decides whether a page may read a reply. It doesn't decide on the server who may act.
- SPAs should use the authorization code flow with PKCE and
state. Decoding a JWT is not verifying it.
Final definition: safe sign-in in the browser uses several layers. Choose how the proof of who you are travels. Block fake writes from other sites. Keep tokens away from page scripts when you can. And check every token or session on the server.
Up next: prototype pollution, where merging untrusted objects can change every object in your app.