Securing Express with Helmet: CSP, HSTS, Frame Options and the Headers That Matter

Key takeaways

Helmet helps secure Express apps by setting various HTTP headers. It's a collection of 15 smaller middleware functions that set security-related headers.

Introduction

Helmet helps secure Express apps by setting HTTP security headers. It’s not a silver bullet, but it provides an important layer of defense against common attacks.

Why Helmet?

Without Helmet, your Express app exposes information and is vulnerable:

// Without Helmet
app.get('/', (req, res) => {
  res.send('Hello');
});

// Response headers:
// X-Powered-By: Express (exposes framework)
// (missing security headers)

With Helmet:

app.use(helmet());

// Response headers now include:
// Content-Security-Policy: ...
// X-DNS-Prefetch-Control: off
// X-Frame-Options: SAMEORIGIN
// Strict-Transport-Security: ...
// X-Content-Type-Options: nosniff
// (and more!)

Worth being upfront about what Helmet actually is before diving into the specifics: it’s a curated collection of response-header settings, not a firewall or a scanner — it doesn’t inspect requests, block malicious traffic, or patch vulnerabilities in your own application code. What it actually does is close off entire categories of browser-level attacks by telling the browser itself how to behave more defensively when rendering your responses — clickjacking, MIME-sniffing exploits, and (with a properly configured CSP) a meaningful chunk of XSS impact, all mitigated by headers the browser reads and enforces, not by anything Helmet inspects in the request. That distinction matters for expectations: Helmet is genuinely close to “free” defense-in-depth (the FAQ’s “no silver bullet” framing is accurate) — it raises the baseline for every response with almost no ongoing maintenance, but it doesn’t replace input validation, parameterized queries, or authentication logic, which remain entirely your application’s responsibility.

Installation

npm install helmet

Basic Usage

const express = require('express');
const helmet = require('helmet');

const app = express();

// Use Helmet with defaults
app.use(helmet());

app.get('/', (req, res) => {
  res.send('Hello, secure world!');
});

app.listen(3000);

This one line is worth taking at face value — Helmet’s defaults were deliberately chosen to be safe and broadly compatible out of the box, so app.use(helmet()) alone genuinely improves a fresh Express app’s security posture with zero configuration, before you’ve thought about CSP directives or HSTS at all. The place this bites people isn’t the default itself, it’s assuming the defaults are also sufficient for a real app long-term — the default CSP, in particular, is conservative enough that it will actively break any app loading scripts/styles/images from external domains (a CDN, a font provider, an analytics script) the moment those are added, which is exactly the class of issue Section 4 onward walks through fixing.

What Helmet Does

Helmet is a collection of 15 smaller middleware:

app.use(helmet());

// Equivalent to:
app.use(helmet.contentSecurityPolicy());
app.use(helmet.crossOriginEmbedderPolicy());
app.use(helmet.crossOriginOpenerPolicy());
app.use(helmet.crossOriginResourcePolicy());
app.use(helmet.dnsPrefetchControl());
app.use(helmet.frameguard());
app.use(helmet.hidePoweredBy());
app.use(helmet.hsts());
app.use(helmet.ieNoOpen());
app.use(helmet.noSniff());
app.use(helmet.originAgentCluster());
app.use(helmet.permittedCrossDomainPolicies());
app.use(helmet.referrerPolicy());
app.use(helmet.xssFilter());

Each of these sub-middlewares maps to one specific HTTP header (or a small related cluster of them), which is exactly why Helmet is composable the way Section 11 demonstrates — disabling contentSecurityPolicy doesn’t touch frameguard’s clickjacking protection at all, since they’re genuinely independent pieces bundled together for convenience, not one monolithic feature. Worth knowing that a couple of these (xssFilter, which sets the old X-XSS-Protection header) are effectively legacy holdovers — that particular header was a stopgap browsers have since removed support for entirely (modern Chrome and Firefox ignore it), superseded by CSP doing the job properly; Helmet still sets it for older browser compatibility, but CSP (covered next) is where the real XSS mitigation actually lives today.

Content Security Policy (CSP)

CSP prevents XSS attacks by controlling which resources can be loaded:

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.example.com"],
    styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
    imgSrc: ["'self'", "data:", "https:"],
    fontSrc: ["'self'", "https://fonts.gstatic.com"],
    connectSrc: ["'self'", "https://api.example.com"],
    frameSrc: ["'none'"],
    objectSrc: ["'none'"],
    upgradeInsecureRequests: [],
  },
}));

CSP’s actual mechanism is worth understanding precisely, since it’s what makes it a genuine XSS mitigation rather than just another header: it doesn’t detect or block malicious input, it constrains what the browser is willing to execute or load regardless of where a script tag came from — so even if an attacker successfully injects <script>evil()</script> into a page (a real XSS vulnerability existing somewhere in your app), a properly configured scriptSrc without 'unsafe-inline' means the browser simply refuses to run that injected inline script, because CSP only permits script execution from the explicitly allowed sources. This is exactly why 'unsafe-inline' is such a consequential thing to add to scriptSrc — it’s not a minor convenience flag, it’s opting out of CSP’s single most important protection, re-permitting exactly the inline-script execution vector CSP exists to close. defaultSrc is worth understanding as a fallback, not a catch-all override: it only applies to a resource type that has no more specific directive of its own present in the policy — scriptSrc present in the same policy fully governs scripts, defaultSrc never adds to it.

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],
    styleSrc: ["'self'"],
    imgSrc: ["'self'"],
    fontSrc: ["'self'"],
    connectSrc: ["'self'"],
    frameSrc: ["'none'"],
    objectSrc: ["'none'"],
  },
}));

This strict form — every directive locked to 'self', nothing external permitted at all — is worth treating as the actual security baseline to aim for and relax deliberately from, rather than the default CSP shown in the previous example being treated as already-strict. objectSrc: ["'none'"] specifically deserves its own callout: <object>/<embed>/<applet> tags are a historically significant vector for loading Flash/Java-based exploits, and since almost no modern app has a legitimate reason to load plugin content this way, disabling it entirely (rather than trying to allow-list trusted sources) is both safe and recommended even in an otherwise more permissive policy.

CSP for React/Vue Apps

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'"], // React inline scripts
    styleSrc: ["'self'", "'unsafe-inline'"], // CSS-in-JS
    imgSrc: ["'self'", "data:", "https:"],
    connectSrc: ["'self'", process.env.API_URL],
  },
}));

Worth pushing back on the comment in this specific example before copying it — a typical React or Vue app, built by a standard bundler, compiles JSX/components down to regular external .js bundle files loaded via <script src="...">, not literal inline <script>...</script> tags, so most React apps genuinely don’t need 'unsafe-inline' on scriptSrc at all. The real case that does need it — or, better, the nonce-based solution covered in Section 15’s Common Issues — is specifically server-side-rendered apps injecting initial state directly into the HTML as an inline script (<script>window.__INITIAL_STATE__ = {...}</script>), which is a narrower situation than “using React” generally. Reaching for 'unsafe-inline' reflexively because a project uses a frontend framework, rather than because it genuinely has inline scripts that need it, gives up CSP’s core XSS protection for a compatibility problem that usually doesn’t actually exist.

HTTP Strict Transport Security (HSTS)

Forces HTTPS connections:

app.use(helmet.hsts({
  maxAge: 31536000, // 1 year in seconds
  includeSubDomains: true,
  preload: true,
}));
// Worth noting styleSrc's 'unsafe-inline' in the previous example is a
// more defensible tradeoff than scriptSrc's — many CSS-in-JS libraries
// (styled-components, emotion) genuinely do inject real inline <style>
// tags at runtime as their core mechanism, and CSP has no nonce-free
// equivalent workaround as clean as the script case's external-bundle
// alternative. Inline style injection is also a meaningfully lower-impact
// attack surface than inline script execution — CSS alone can't exfiltrate
// data or run arbitrary logic the way injected JavaScript can — which is
// part of why relaxing styleSrc is a more commonly accepted compromise
// than relaxing scriptSrc.

// Only enable in production with HTTPS
if (process.env.NODE_ENV === 'production') {
  app.use(helmet.hsts({
    maxAge: 31536000,
    includeSubDomains: true,
  }));
}

HSTS is worth understanding as fundamentally different from the other headers in this guide — it’s not protecting against a browser-rendering vulnerability, it’s closing a specific network-level attack window on the first connection: without it, a user typing example.com (no protocol) or clicking an old http:// link connects over plain HTTP first, and only gets redirected to HTTPS after that initial insecure request — a window during which a man-in-the-middle attacker on the same network (public wifi, a compromised router) can intercept or tamper with that first request before the redirect ever happens. HSTS tells the browser “remember, for maxAge seconds, to never even attempt plain HTTP for this domain again” — meaning subsequent visits skip the vulnerable window entirely by upgrading to HTTPS before any network request goes out at all. The preload flag is worth understanding as a one-way door: it’s an opt-in to a hardcoded list baked directly into browser source code (Chrome, Firefox, Safari all ship one), which closes the vulnerable first-ever visit too, but submission and removal both take real time — genuinely test HSTS thoroughly with preload: false first, since a preloaded domain forced into HTTPS with a broken certificate is a real, difficult-to-quickly-undo outage.

X-Frame-Options

Prevents clickjacking:

// Deny all framing
app.use(helmet.frameguard({ action: 'deny' }));

// Allow same origin
app.use(helmet.frameguard({ action: 'sameorigin' }));

// Allow specific domain
app.use(helmet.frameguard({
  action: 'allow-from',
  domain: 'https://example.com'
}));

Clickjacking is worth spelling out concretely since “prevents clickjacking” alone doesn’t convey the actual attack: a malicious site embeds your page in an invisible or disguised <iframe>, overlays it with its own convincing UI, and tricks a logged-in user into clicking what looks like the attacker’s button but is actually a real click landing on your page underneath — a “delete account” or “transfer funds” button, say, that the user never knowingly agreed to click. frameguard’s deny/sameorigin settings tell the browser to simply refuse to render the page inside a frame at all (or only within a frame from the same origin), which eliminates the attack outright rather than trying to detect it — worth knowing allow-from is the deprecated form, since it’s been removed from the actual header spec that modern browsers implement in favor of CSP’s frame-ancestors directive, which is the currently-correct way to allow-list specific framing origins.

X-Content-Type-Options

Prevents MIME sniffing:

app.use(helmet.noSniff());

// Sets header:
// X-Content-Type-Options: nosniff

MIME sniffing is a genuinely non-obvious attack vector worth understanding concretely: some browsers, historically, would try to “guess” a resource’s real content type by inspecting its actual bytes rather than trusting the server’s declared Content-Type header — which sounds like a helpful compatibility feature, but meant a file served as Content-Type: text/plain (an uploaded user avatar, say, that actually contains embedded HTML/script content) could get reinterpreted and executed as HTML/JavaScript by a browser that decided the sniffed content looked more like a script than plain text. nosniff tells the browser to trust the declared Content-Type exactly and never override it based on content inspection — closing off file-upload-based XSS vectors that rely on browsers second-guessing a server’s own type declaration.

Referrer Policy

Controls Referer header:

app.use(helmet.referrerPolicy({
  policy: 'strict-origin-when-cross-origin'
}));

// Options:
// - no-referrer
// - no-referrer-when-downgrade
// - origin
// - origin-when-cross-origin
// - same-origin
// - strict-origin
// - strict-origin-when-cross-origin (recommended)
// - unsafe-url

The Referer header (note the header’s own famously misspelled name, baked permanently into the HTTP spec since the 1990s) tells the destination site which page a user navigated from — genuinely useful for analytics, but also a real privacy leak: a link on a page containing a sensitive URL parameter (a password-reset token, an unlisted document’s URL, an internal search query) can leak that entire URL to whatever third-party site the user clicks through to, entirely outside your control once it’s in another site’s server logs. strict-origin-when-cross-origin, the recommended setting here, is a genuinely well-chosen middle ground worth understanding rather than just copying — it sends the full URL for same-origin navigation (useful internal analytics), only the bare origin (no path, no query string) for cross-origin HTTPS-to-HTTPS navigation, and nothing at all when downgrading from HTTPS to HTTP, layering three different privacy levels onto three different real-world scenarios rather than a single blanket policy.

Hide Powered By

Remove X-Powered-By header:

app.use(helmet.hidePoweredBy());

// Or manually:
app.disable('x-powered-by');

// Or fake it:
app.use(helmet.hidePoweredBy({ setTo: 'PHP 4.2.0' }));

This is worth understanding as security-through-obscurity rather than a genuine defense — hiding X-Powered-By: Express doesn’t patch any vulnerability, it just removes one convenient signal an attacker doing reconnaissance might use to select which known Express/Node vulnerabilities to try first. A determined attacker can usually still fingerprint the underlying framework from other, harder-to-hide signals (error page formats, response timing characteristics, header ordering), so this belongs firmly in the “cheap, harmless, marginal benefit” category rather than something to rely on as real protection — the setTo: 'PHP 4.2.0' option specifically is amusing but not a meaningful additional deterrent, since anyone actually probing your app methodically will discover the truth quickly regardless of what this header claims.

Custom Configuration

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://cdn.example.com"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
  },
  frameguard: {
    action: 'deny',
  },
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin',
  },
}));

Passing configuration directly to helmet({...}) with nested per-middleware keys, as shown here, is worth preferring over calling each helmet.xxx() sub-middleware individually the way Section 4-9 demonstrated for teaching purposes — the single-call form is what current Helmet documentation actually recommends, since it composes all the middleware into one pass and keeps every setting visible in one place rather than scattered across multiple app.use() calls that could accidentally be reordered, duplicated, or partially forgotten during a refactor.

Disable Specific Middleware

app.use(helmet({
  contentSecurityPolicy: false, // Disable CSP
  frameguard: false, // Disable X-Frame-Options
}));

Disabling a specific piece of Helmet is a legitimate, common need (an app that genuinely needs to be embeddable in an iframe on another site has to disable frameguard, for instance), but it’s worth treating each disable as a deliberate, documented tradeoff rather than a quick fix for a confusing error — disabling CSP entirely because a script got blocked papers over the actual problem (a missing source in your allow-list) rather than fixing it, and quietly reintroduces the exact XSS surface CSP exists to close. The right instinct when something breaks under Helmet is almost always “add the specific missing source to the specific directive,” which Section 15’s Common Issues walks through — reaching for a blanket false should be the exception, made consciously, not the default troubleshooting step.

Production Configuration

const express = require('express');
const helmet = require('helmet');

const app = express();

// Production-ready Helmet config
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", process.env.CDN_URL],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", process.env.API_URL],
      fontSrc: ["'self'", "https://fonts.gstatic.com"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
  frameguard: {
    action: 'deny',
  },
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin',
  },
}));

// Additional security
app.disable('x-powered-by');

// Trust proxy (if behind reverse proxy)
app.set('trust proxy', 1);

app.listen(process.env.PORT || 3000);

app.set('trust proxy', 1) is worth understanding as a genuinely necessary companion setting whenever an app sits behind a reverse proxy or load balancer (Nginx, an AWS ALB, Cloudflare) — without it, Express sees every incoming connection as coming from the proxy’s own internal IP, not the real client, which breaks anything relying on req.ip or req.secure being accurate: rate limiting keyed by client IP silently rate-limits the proxy instead of individual users, and HSTS/secure-cookie logic checking req.secure can incorrectly conclude every request is insecure even when the original client connection genuinely was HTTPS (with the proxy terminating TLS and forwarding plain HTTP internally). This is a real, easy-to-miss production gotcha specifically because it works perfectly fine in local development (no proxy in the way) and only manifests once actually deployed behind one.

With CORS

const helmet = require('helmet');
const cors = require('cors');

// Apply Helmet first
app.use(helmet());

// Then CORS
app.use(cors({
  origin: process.env.FRONTEND_URL,
  credentials: true,
}));

Helmet and CORS are worth understanding as solving genuinely different, non-overlapping problems despite both being “security middleware” — Helmet governs how the browser itself should treat and render your responses (frame embedding, script execution, MIME handling), while CORS (covered in more depth in the dedicated CORS guide elsewhere on this site) governs whether JavaScript running on a different origin is allowed to read your responses at all. Neither substitutes for the other, and the order they’re applied in generally doesn’t matter functionally (both operate on the outgoing response independently) — Helmet first here is simply a readable convention, not a functional requirement the way, say, resolve() before commonjs() was in the Rollup guide’s plugin ordering.

Testing Security Headers

# Check headers with curl
curl -I https://example.com

# Look for:
# Content-Security-Policy: ...
# Strict-Transport-Security: max-age=31536000; includeSubDomains
# X-Frame-Options: DENY
# X-Content-Type-Options: nosniff
# Referrer-Policy: strict-origin-when-cross-origin

Checking headers with curl -I is worth doing as a real, direct verification step after any Helmet config change, not just trusting the config looks right — it’s genuinely common to configure something correctly in code and still see the wrong header in production because of a caching layer (a CDN or reverse proxy serving a stale cached response from before the config change), a different environment picking up different config than expected, or a competing middleware overwriting the same header later in the chain. Seeing the actual header value on the actual live response is the only way to be certain the configuration took effect exactly as intended.

Security Scanners

# Mozilla Observatory
# https://observatory.mozilla.org/

# Security Headers
# https://securityheaders.com/

# SSL Labs
# https://www.ssllabs.com/ssltest/

These free scanners are worth running before shipping a “production-ready” Helmet config, specifically because they check for things easy to overlook by reading your own config in isolation — Mozilla Observatory and Security Headers both grade the actual live response against a broader, continuously-updated checklist of header best practices than any single guide (including this one) can fully enumerate, and SSL Labs specifically audits the TLS configuration itself (cipher suites, protocol versions, certificate chain) that sits underneath everything Helmet does at the header level — a genuinely different, complementary layer of the same overall security picture.

Common Issues

Issue 1: CSP Blocking Inline Scripts

// Problem: React/Vue inline scripts blocked
<script>window.__INITIAL_STATE__ = {...}</script>

// Solution 1: Use nonce
app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString('base64');
  next();
});

app.use(helmet.contentSecurityPolicy({
  directives: {
    scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
  },
}));

// In template:
<script nonce="<%= nonce %>">...</script>

// Solution 2: Allow unsafe-inline (less secure)
app.use(helmet.contentSecurityPolicy({
  directives: {
    scriptSrc: ["'self'", "'unsafe-inline'"],
  },
}));

Nonces are the correct, secure fix for genuinely-necessary inline scripts (like the initial-state injection this example addresses) — worth understanding why they work when 'unsafe-inline' alone doesn’t: a nonce is a fresh, cryptographically random value generated per request (hence crypto.randomBytes inside the middleware, computed fresh every single time, never reused), embedded both in the CSP header and as an attribute on the specific inline <script> tag you actually authored. The browser only executes an inline script whose nonce attribute matches the one in that response’s CSP header — and since an attacker injecting a script via XSS has no way to know or guess the correct per-request nonce value (it changes every request and never appears anywhere an attacker could read it in advance), their injected script simply won’t have a matching nonce and won’t execute, while your own legitimately-rendered inline script does. This is precisely the difference between a targeted, cryptographically-sound allowance for one specific trusted script and 'unsafe-inline'’s blanket “run any inline script, trusted or not” — the second solution shown below trades that real protection away for simplicity, and is explicitly labeled “less secure” for exactly that reason.

Issue 2: HSTS Breaking Local Development

// Only enable HSTS in production
if (process.env.NODE_ENV === 'production') {
  app.use(helmet.hsts({
    maxAge: 31536000,
  }));
}

This one is worth understanding at the browser-caching level, since it’s not just “HSTS looks weird locally” — once a browser receives an HSTS header for localhost (or any domain) even a single time, it remembers that policy client-side for the full maxAge duration and will refuse to connect over plain HTTP to that host again until the policy expires, regardless of whether your dev server ever sends the header again. This means accidentally enabling HSTS locally even once can leave a developer’s browser refusing http://localhost:3000 for up to a year afterward, with the fix (chrome://net-internals/#hsts, manually deleting the domain’s HSTS policy) being a genuinely obscure browser setting most developers have never had to find before hitting exactly this problem.

Issue 3: CSP Blocking CDN Resources

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: [
      "'self'",
      "https://cdn.jsdelivr.net",
      "https://cdnjs.cloudflare.com",
    ],
    styleSrc: [
      "'self'",
      "https://fonts.googleapis.com",
    ],
  },
}));

This pattern — hitting a CSP block, then adding the specific missing source to the specific directive it’s actually blocked under — is the correct troubleshooting instinct referenced earlier, worth practicing deliberately: the browser’s own console reports exactly which directive blocked which resource and why (open dev tools, reload, read the CSP violation message), which tells you precisely what to add rather than guessing. Notice scriptSrc and styleSrc get their own separate CDN allow-lists here rather than both falling back to a shared defaultSrc list — that’s deliberate precision, not redundancy: a CDN serving your fonts shouldn’t automatically also be trusted to serve executable scripts just because it’s in some shared list, and keeping each directive’s allow-list scoped to only what that specific resource type actually needs is what keeps a CSP meaningfully restrictive rather than gradually becoming one big permissive list everything ends up trusted under.

Rolling out CSP without breaking production

Keep Helmet on in development too

// Development
app.use(helmet({
  contentSecurityPolicy: false, // Easier debugging
}));

// Production
app.use(helmet()); // Full security

Disabling CSP entirely in development for easier debugging is a reasonable pragmatic tradeoff, but worth knowing what it actually costs: it means your dev environment never exercises the exact policy production will enforce, so a CSP violation that would break a feature in production can go completely unnoticed through the entire development cycle and only surface once real users hit it live. Report-only mode (covered next) is the better middle ground for exactly this reason — it keeps CSP genuinely active during development (so violations are visible) without CSP actually blocking anything and breaking your ability to work.

Start with report-only mode

// Test CSP in report-only mode first
app.use(helmet.contentSecurityPolicy({
  directives: { /* ... */ },
  reportOnly: true, // Only report violations, don't block
}));

reportOnly: true is genuinely one of the most useful tools in this entire guide for rolling out a new or tightened CSP safely on an existing app with unknown resource-loading patterns — the browser evaluates the policy exactly as it normally would and reports every violation it would have blocked, but lets every resource load anyway, which means you can deploy a strict policy to real production traffic, gather real violation data from real user sessions for days or weeks, and only flip to actual enforcement once you’re confident the policy doesn’t break anything a real user does. This turns “will this CSP break something in a code path I didn’t think to test” from a guess into an answerable question backed by real data, before it has any actual user-facing consequence.

Collect the violation reports

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    reportUri: '/api/csp-violation-report',
  },
}));

// Browsers send report-uri reports as application/csp-report, not application/json
app.post(
  '/api/csp-violation-report',
  express.json({ type: ['application/json', 'application/csp-report'] }),
  (req, res) => {
    console.log('CSP Violation:', req.body);
    // Log to monitoring service
    res.status(204).end();
  }
);

The type option on the body parser is the detail that usually goes wrong here. A plain app.use(express.json()) only parses requests whose Content-Type is application/json, while browsers send report-uri reports as application/csp-report. The endpoint still receives the POST and returns 204, but req.body is an empty object, so the logs fill with violations that carry no information and nothing looks broken.

reportUri (and its reportOnly counterpart above) is what makes CSP genuinely observable in production rather than a fire-and-forget config that might be silently blocking things you’d never otherwise notice — the browser POSTs a structured JSON report to this endpoint every time it blocks or would-block a resource, including exactly which directive and which blocked URI triggered it, which is far more actionable than a user vaguely reporting “the page looks broken.” Worth knowing reportUri itself is the older, still-widely-supported directive; the newer report-to directive (paired with a Report-To header) is the direction the spec has moved, offering more structured reporting endpoints and better batching, though reportUri remains functional and simpler to wire up for a first implementation like the one shown here. Returning a bare 204 with no body is the conventional, correct response for a reporting endpoint — the browser doesn’t do anything with the response body, so there’s nothing to send back.