completefrontend
Open lab My progress
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 the browser sends a cookieTell origins and sites apart, then say whether a SameSite cookie goes with a request that changes something.
  • 02
    Choose 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.
  • 03
    Keep 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.

Definition
  • 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.
Real-life analogyA McDonald's order token

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
Safe examples only

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

FOUNDATION

Browsers 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. So app.example.co.uk and api.example.co.uk are 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.

Same origin or same site?Pop 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));

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 in example.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.

Two common kinds of session and their main risk
Kind of sessionHow a request proves who you areThe main risk in the browser
Cookie session IDThe 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 tokenJavaScript 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 THROUGH

A 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: GET requests that only read. Keep GET safe: it should never change anything, because links, preloading, search crawlers, and images can all send a GET.
  • 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.

A cookie with no SameSite counts as LaxPop out in the code editor (opens in a new tab)JavaScript
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.

A page on attacker.example sends a POST to bank.example. The bank's session cookie with SameSite=Lax is not sent. The same cookie with SameSite=None is sent.another site's pageattacker.examplePOST /transferto bank.examplebank session cookieSameSite=Laxnot sentSameSite=Nonesent
Another site's page sends a 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: is the session cookie sent?
Step 0 of 4Ready
Your turn: follow the blue line

Step through whether the browser sends a session cookie with a request.

Running in
  1. script
Next: line 13
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));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Request to step through
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.
Is the cookie sent? Try it
Same origin?no
Same site?no
Site of the pagehttps://attacker.example
Site of the requesthttps://bank.example
Cookieblocked
Try it yourself

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

A small model: a site is the scheme plus the registrable domain, found with a tiny sample of the Public Suffix List. Real browsers use the whole list.

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.

Which SameSite value to choose
  • 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 a GET that changes the address bar. Don't send it on cross-site POST requests, 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 CHECKS

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

Check the token a request sendsPop out in the code editor (opens in a new tab)JavaScript
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 gives true only 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.
The token saved in the session is random-session-token. A request that sends the same token gives true. A request that sends an empty string gives false.token saved in the sessionrandom-session-tokenrandom-session-tokensends the same tokentrue""sends an empty stringfalse
The server keeps one secret token for each signed-in session. 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.

A server checks a write before doing itJavaScript
// 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.test also starts with https://app.example.
  • If both headers are missing or broken, reject the write.
Check Origin, then RefererPop out in the code editor (opens in a new tab)JavaScript
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. The Origin is the app.
  • Line 8: false. The Referer is from another site.
  • Line 9: false. Both headers are missing.
  • Line 10: true. Origin is missing, and the Referer is from the app.
  • Line 11: false. The Origin is 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.

Accept only JSON with the CSRF headerPop out in the code editor (opens in a new tab)JavaScript
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't make an HttpOnly cookie

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.

A cookie that JavaScript can seePop 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=/";

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.

Cookies set by the serverHTTP
Set-Cookie: __Host-session=abc123; Path=/; HttpOnly; Secure; SameSite=LaxSet-Cookie: csrf=public-token; Path=/; Secure; SameSite=Lax

Line 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

CHOICES

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

Any script on the page can read the tokenPop 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");
  • Line 1 uses a Map to stand in for localStorage, 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.
Left: an access_token in localStorage. Any script on the page can read it. Right: a __Host-session cookie set with HttpOnly. Scripts on the page can't read it.localStorageaccess_tokenstoragea script reads itHttpOnly cookie__Host-sessioncookiesscripts can't read it
Any script on the page can read 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 to keep a token: what each choice protects
Where the token livesWhat it protectsWhat is still risky
localStorageIt 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.
sessionStorageIt 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 cookieThe 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 sessionThe 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.

How many seconds until the token expires?Pop out in the code editor (opens in a new tab)JavaScript
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.

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

Sort each defense by the main risk it handles on its own. Some defenses only help with both when you combine them.

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

CORS doesn't decide who may act

TRY IT

CORS (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.

A page on app.example sends a POST to api.example. The request is sent and the server gets it. The reply has no CORS headers, so the page can't read it.app.examplePOST /transferrequest is sentpage can't read itapi.examplegets the POST
The POST still reaches the server. Without CORS headers on the reply, the browser only stops the page from reading it.
Step through: may the page read the reply?
Step 0 of 4Ready
Your turn: follow the blue line

Step through the two things CORS keeps apart: sending the request, and letting the page read the reply.

Running in
  1. script
Next: line 14
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Request and reply to step through
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.
May the page read the reply? Try it
A small model of the CORS rulesPop 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);
What the browser doesblocked
Kind of requestsimple
Real requestsent
Page can readno
Try it yourself

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

This models the browser's CORS rules. It doesn't decide who may act: the server must still check every request.

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 HASH

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

The four OAuth roles
RoleWho it is in this lesson
Resource ownerThe person who owns the data and says yes to sharing it.
ClientThe 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 serverIt checks who the user is, then hands out authorization codes and tokens.
Resource serverThe 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):

  1. The app makes a long random string, the code_verifier, and keeps it.
  2. It hashes the verifier into a code_challenge and sends that with the sign-in redirect.
  3. After the user signs in, the app gets a one-time authorization code.
  4. 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.

OAuth roles and flow, in shortText
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
Make a PKCE challenge
Make the challenge from the verifierPop 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...
Matches the standard's example?no
Try it yourself

You changed the verifier, so its SHA-256 hash, the code challenge, changed too.

The browser hashes the verifier with SHA-256 using crypto.subtle.digest, then writes the bytes as base64url text. Nothing secret leaves the page.

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 CAREFULLY

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

Decode a JWT without trusting 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");
  • 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, then 123, then read, not verified. Nothing here checked the signature.
OpenID Connect in short

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, and email.

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. state protects 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.

Four ideas that are easy to mix up
IdeaWhat it doesWhat it doesn't do
CORSIt 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.
SameSiteIt 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.
HttpOnlyIt stops page scripts from reading a cookie's value.It doesn't stop the browser from sending the cookie.
JWT decodingIt turns the header and payload from base64url back into text.It doesn't check the signature, so it proves nothing.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict SameSite=Lax

Does the browser send this Lax cookie? Predict what the program prints.

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: sent or readable?

    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 is the problem?

      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 the 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 most at risk from CSRF?

              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 can't Access-Control-Allow-Origin: * be used 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 8Which OAuth flow should a browser SPA use today?

              Choose an answer to see the explanation.

            8. 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 GET safe. Set SameSite on purpose. Check a CSRF token and the Origin header 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 localStorage or sessionStorage.
            • HttpOnly cookies 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.

            CompleteFrontend Clear concepts. Working examples.