Secure by Default for Node Apps
A practical playbook for shipping Node.js with Helmet, strong CSP, and clean dependencies — without slowing down your team.
Make your Node.js app secure by default. Learn Helmet setup, battle-tested Content Security Policy patterns, and dependency hygiene to prevent supply-chain risk.
Security is rarely one big lock. It’s lots of tiny latches in the right places.
Ship a Node app fast, and it’s easy to skip the latches — until a report lands in your inbox. Let’s be real: you don’t need a security team to raise your floor. You need good defaults.
Below is the opinionated, field-tested baseline I use to harden Express apps in a day. It’s simple enough for small teams, strong enough for audits, and flexible for growth.
What “Secure by Default” Means in Node
Make the safe path the easiest path:
- Deny by default in the browser with a Content Security Policy (CSP).
- Turn on sane headers via Helmet so you don’t forget the alphabet soup.
- Treat dependencies as part of your code with hygiene rules: locked, scanned, and trimmed.
Here’s the shape of it:
[Request] -> [Express] -> [Helmet middleware] -> [Routes/Views]
|
v
[CSP Builder + Nonce]
|
v
[Responses w/ security headers + strict CSP]Step 1 — Helmet: the Fastest Security Win
Helmet sets a suite of HTTP headers that mitigate common attacks: XSS, clickjacking, MIME sniffing, and more.
Install + minimal config
npm i helmet// app.js
import express from 'express';
import helmet from 'helmet';
const app = express();
// Base protections
app.use(helmet({
// Explicit to make behavior clear in reviews
frameguard: { action: 'deny' }, // Prevent clickjacking
xssFilter: false, // Deprecated; rely on CSP
noSniff: true, // Stop MIME sniffing
hidePoweredBy: true, // Don’t leak Express
referrerPolicy: { policy: 'no-referrer' },
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-origin' },
crossOriginEmbedderPolicy: { policy: 'require-corp' } // if compatible
}));
// Example route
app.get('/healthz', (_req, res) => res.send('ok'));
app.listen(3000);Why this matters: You get a safer default surface without touching business logic. And when a framework update changes header behavior, Helmet handles it.
You might be wondering: do these headers break third-party widgets? Sometimes. That’s where CSP comes in — with precision instead of broad allow-alls.
Step 2 — Content Security Policy That Actually Works
A CSP tells the browser what it’s allowed to load and execute. Done right, it crushes XSS and rogue scripts. Done sloppy, it’s a permission slip for problems.
Principles:
- Prefer nonce-based execution for inline scripts.
- Avoid
unsafe-inlineandunsafe-eval. - Start in Report-Only mode to collect violations, then enforce.
Nonce-based CSP with Helmet
// csp.js
import crypto from 'node:crypto';
export function csp() {
return (req, res, next) => {
// Per-response nonce
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
const cspDirectives = {
"default-src": ["'none'"],
"script-src": ["'self'", `'nonce-${nonce}'`],
"style-src": ["'self'", "https://fonts.googleapis.com"],
"img-src": ["'self'", "data:"],
"font-src": ["'self'", "https://fonts.gstatic.com"],
"connect-src": ["'self'"], // APIs, websockets
"frame-ancestors": ["'none'"], // Clickjacking defense
"base-uri": ["'self'"], // Stop base tag rewriting
"form-action": ["'self'"], // Limit form posts
"object-src": ["'none'"], // No Flash/plugins
"upgrade-insecure-requests": [] // Optional
};
const toHeader = (d) =>
Object.entries(d).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');
// During rollout, use Content-Security-Policy-Report-Only
res.setHeader('Content-Security-Policy', toHeader(cspDirectives));
next();
};
}Wire it up:
import { csp } from './csp.js';
app.use(csp());Use the nonce in your server-rendered template:
<script nonce="{{cspNonce}}">
// Safe inline boot script
window.__BOOT__ = {...};
</script>Debugging tips:
- Add
report-to/report-uriif you run CSP reports. - Keep third-party domains explicit (e.g., your analytics
script-srcandconnect-src). - Bundle app JS to avoid sprinkling inline scripts that fight the policy.
Step 3 — Dependency Hygiene (The Boring Part That Saves You)
Most incidents in Node aren’t “0-day ninjas.” They’re stale packages, over-broad dependencies, or typosquats. Treat the supply chain like your codebase.
The rules I enforce
- Lockfiles are sacred.
Commitpackage-lock.json(orpnpm-lock.yaml,yarn.lock). CI should usenpm ci, nevernpm install, to ensure reproducible builds.
2. Pin your runtime.
Use an .nvmrc or engines field:
{
"engines": { "node": ">=20 <21" }
}3. Minimal dependencies.
Prefer stdlib (Node 20+ is generous) or tiny, maintained libs. Remove abandoned packages; one fewer transitive dep is one fewer wake-up call.
Get Thinking Loop’s stories in your inbox
Join Medium for free to get updates from this writer.
4. Automated scanning.
Run npm audit --production in CI. Pair with a secondary scanner (SCA from your platform or ossf/scorecard) for coverage.
5. Scoped registries & provenance.
Lock to npm’s official registry (or your private mirror). In CI, use read-only tokens. If your platform supports it, verify package provenance and signatures.
6. No postinstall surprises.
Block scripts that run on install in CI unless explicitly allowed:
npm ci --ignore-scripts7. Review new transitive deps.
On PRs that bump packages, print a diff of dependency trees and license changes. Small habit, big payoff.
A small CI recipe
# .github/workflows/security.yml
name: security
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci --ignore-scripts
- run: npm audit --omit=dev --audit-level=moderateIf you ship a CLI or library, run audits on publish too.
A Real-World Pattern: Tight Frontend + Strict Backend
Many teams want React/Vite speed plus server-side safety. Here’s a sketch that keeps CSP strict and DX pleasant:
[React bundle] --(hashed files)--> [CDN]
\ ^
\--(nonce boot)--> [SSR HTML via Express + Helmet + CSP]
|
v
[API routes / GraphQL]- The HTML includes a tiny nonced boot script only.
- All heavy JS is hashed and served from your domain/CDN in
script-src 'self'. - Third-party analytics: load via
script-src https://<vendor>and send beacons usingconnect-srconly to whitelisted hosts. - No inline event handlers, ever.
Quick Checks That Catch 80% of Issues
- Does every response have your CSP? Add an integration test that fetches a few URLs and asserts the header exists.
- Any
unsafe-inlineleft? If yes, you’re postponing the fix. Move to nonce. - Are you leaking framework info? Helmet’s
hidePoweredByshould kill it. - Do your dependencies change without a PR? If builds use
npm install, they might. Switch tonpm ci. - Do you know when a package turns critical? Subscribe to advisories for your top 10 deps or enable SCA alerts.
Common Pitfalls (and the fix)
- “CSP broke my third-party widget.”
Set a Report-Only policy, watch violations, explicitly permit required hosts. If the vendor needsunsafe-inline, find a new vendor or proxy a sandboxed version. - “Helmet’s COEP/COOP blocked my embeds.”
Iframes from other origins may conflict. Decide: do you need cross-origin isolation (for performance & security) or liberal embeds? Document the trade-off and tune per-route if necessary. - “Audit is red but the patch is breaking.”
Useresolutions/overridesto patch transitive deps when maintainers lag. Add tests that cover the behavior you fear will regress.
A Starter Template You Can Drop In
// security.ts (works in JS too with minor changes)
import crypto from 'node:crypto';
import helmet from 'helmet';
import type { RequestHandler } from 'express';
export const security = [
helmet({
frameguard: { action: 'deny' },
noSniff: true,
hidePoweredBy: true,
referrerPolicy: { policy: 'no-referrer' },
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-origin' }
}),
((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
const d = {
"default-src": ["'none'"],
"script-src": ["'self'", `'nonce-${nonce}'`],
"style-src": ["'self'"],
"img-src": ["'self'", "data:"],
"font-src": ["'self'"],
"connect-src": ["'self'"],
"frame-ancestors": ["'none'"],
"base-uri": ["'self'"],
"form-action": ["'self'"],
"object-src": ["'none'"]
};
res.setHeader(
'Content-Security-Policy',
Object.entries(d).map(([k,v]) => `${k} ${v.join(' ')}`).join('; ')
);
next();
}) as RequestHandler
];Usage:
import express from 'express';
import { security } from './security.js';
const app = express();
app.use(security);Wrap-Up
You don’t need a 40-page policy to elevate your Node security. You need defaults that are hard to turn off — and easy to review. Helmet gives you the guardrails. CSP gives you precision. Dependency hygiene removes landmines you didn’t know you had.
If you want a copy-paste checklist (CSP rollout plan, CI snippets, and PR review prompts), say the word — I’ll share the template. And if your app has a gnarly third-party script story, drop it in the comments; I read every one.









