Sitemap
Thinking Loop

Thinking Loop

At Thinking Loop, we dive into AI, systems design, productivity, and the curious patterns of how we think and build. New ideas. Practical code. Deep insight.

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.

6 min readOct 27, 2025

--

Press enter or click to view image in full size

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-inline and unsafe-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-uri if you run CSP reports.
  • Keep third-party domains explicit (e.g., your analytics script-src and connect-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

  1. Lockfiles are sacred.
    Commit package-lock.json (or pnpm-lock.yaml, yarn.lock). CI should use npm ci, never npm 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-scripts

7. 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=moderate

If 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 using connect-src only 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-inline left? If yes, you’re postponing the fix. Move to nonce.
  • Are you leaking framework info? Helmet’s hidePoweredBy should kill it.
  • Do your dependencies change without a PR? If builds use npm install, they might. Switch to npm 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 needs unsafe-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.”
    Use resolutions/overrides to 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.

--

--

Written by Thinking Loop

At Thinking Loop, we dive into AI, systems design, productivity, and the curious patterns of how we think and build. New ideas. Practical code. Deep insight.