
To secure a Node.js application in 2026, harden four layers in this order: dependencies (lockfile, audit, allowlist), runtime (Helmet, Zod, rate limit, parameterized queries), authentication (modern session library, OAuth, secure cookies), and secrets (no .env in the repo, managed vault). The Shai-Hulud npm worm of September 2025 made dependency hygiene non-negotiable. Everything else flows from those four defaults.
Two events reset the threat model in the last year. In September 2025, the self-replicating "Shai-Hulud" worm compromised more than 500 npm packages, harvesting GitHub tokens, AWS keys, and CI secrets from any machine that ran npm install. In April 2026, "Mini Shai-Hulud" hit four packages in the SAP developer ecosystem with roughly 570,000 weekly downloads combined. Both attacks succeeded because most teams trust install scripts by default.
The runtime side moved too. Node 18 reached end-of-life on April 30, 2025. Node 20 LTS support ends in April 2026, which is right now. The current target is Node 22 LTS, with active maintenance through April 2027. The permission model (--permission, --allow-fs-read, --allow-net) graduated from experimental to stable in v23.5.0, giving you capability-based sandboxing without a container.
Your job in 2026 is to assume your dependency tree is hostile, your runtime is exposed, and one leaked token is one Slack alert away from a breach. The four layers below are the floor, not the ceiling.
Most Node.js incidents in 2026 come through the package manager, not the application code. The defenses are unglamorous and cheap.
Lockfile and pinning. Use npm ci in every CI pipeline, never npm install. npm ci requires a clean package-lock.json and refuses to install anything outside it. Pin exact versions for any package that runs at install or build time:
{
"dependencies": {
"express": "5.1.0",
"zod": "3.23.8",
"helmet": "8.0.0"
},
"engines": {
"node": ">=22.11.0 <23.0.0"
}
}
Block install scripts. This is the single biggest change Shai-Hulud forced. Add this once to your shell and your CI runners:
npm config set ignore-scripts true
You will need to manually allow scripts for packages that genuinely need them (sharp, bcrypt, native bindings) using npm rebuild <pkg> --foreground-scripts. The trade-off is worth it. The worm's payload depended entirely on postinstall execution.
Layer your scanners. npm audit catches CVE-known issues. That's table stakes. Pair it with one tool that looks at package behavior, not just version numbers:
Add lockfile-lint to your pre-commit hook to catch tampered lockfiles. Run npm audit signatures to verify package provenance against npm's signing keys; npm rolled this out in 2024 and it now covers the top 10,000 packages.
For a deeper take on cleanup work like this, our guide to scaling from MVP to production-ready walks through the order to add monitoring, logging, and dependency hygiene without overengineering. If you want a quick read on whether your current stack is in shape, you can audit your stack on Ship-or-Skip and get an honest grade in five minutes.
Once the dependency tree is trustworthy, the next failure mode is your own code shipping with insecure defaults.
Helmet headers. Helmet ships 11 security headers including Content-Security-Policy, Strict-Transport-Security, and X-Content-Type-Options. The defaults are sane, but CSP needs custom configuration for your asset domains:
import express from "express";
import helmet from "helmet";
const app = express();
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://js.stripe.com"],
imgSrc: ["'self'", "data:", "https://images.cadence.app"],
connectSrc: ["'self'", "https://api.stripe.com"],
},
},
crossOriginEmbedderPolicy: false, // enable only if you serve cross-origin assets
}));
Input validation with Zod. Every public route gets a schema parsed before any business logic runs. This single pattern eliminates most injection, mass-assignment, and type-confusion bugs:
import { z } from "zod";
const CreateInvoiceSchema = z.object({
amountCents: z.number().int().positive().max(10_000_000),
currency: z.enum(["usd", "eur", "gbp"]),
customerId: z.string().uuid(),
notes: z.string().max(500).optional(),
});
app.post("/invoices", async (req, res) => {
const input = CreateInvoiceSchema.parse(req.body); // throws on invalid
const invoice = await invoices.create(input);
res.json(invoice);
});
Parameterized queries. Never concatenate strings into SQL. Drizzle, Prisma, Kysely, and node-postgres all bind parameters automatically; if you use any of them correctly, SQL injection is structurally impossible. For the long version, see our guide to using Drizzle ORM in 2026.
// Safe: pg with parameter binding
const { rows } = await pool.query(
"SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL",
[email]
);
For Mongo, drop $where queries and add express-mongo-sanitize to strip $ operators from user input. Better yet, validate input shape with Zod first so operator injection has no surface.
Prototype pollution and deserialization. Two old classes of bug that still ship in 2026. Use Object.create(null) for any object that will absorb user input via merge:
const safeMerge = (userInput: unknown) => {
const target = Object.create(null);
Object.assign(target, userInput);
return target;
};
Avoid JSON.parse on untrusted input without a schema. Avoid eval, new Function, and vm.runInNewContext on anything user-supplied. Use the --disable-proto=delete flag in production to make __proto__ access return undefined.
Rate limiting. Every public endpoint, especially auth and password reset, needs rate limiting. express-rate-limit with a Redis store covers most apps:
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 attempts per 15 min per IP
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
});
app.post("/auth/login", authLimiter, loginHandler);
If you want a deeper dive on the patterns and limits to pick, we covered rate limiting an API end-to-end here.
Passport.js still works, but its strategy ecosystem is poorly maintained and most strategies haven't shipped a real update since 2022. In 2026 you have three better options:
| Library | Best for | Trade-off |
|---|---|---|
| Lucia | Lightweight session auth, BYO database | You write the OAuth flows yourself |
| Auth.js (NextAuth) | Next.js apps, full OAuth provider catalog | Opinionated; harder to customize edge cases |
| Better-Auth | TypeScript-first, includes 2FA + RBAC + organizations out of the box | Younger project, smaller community |
Pick Lucia if you want a tiny dependency that handles sessions and you'll write OAuth yourself. Pick Auth.js if you're on Next.js and want OAuth with Google, GitHub, Apple, etc. working in an afternoon. Pick Better-Auth if you want batteries-included auth with role-based access control (RBAC), magic links, and 2FA on day one. We break down RBAC design in this 2026 guide if that's the part you're stuck on.
Sessions vs JWTs. Use sessions for web apps. Use JWTs only for stateless service-to-service calls or short-lived API tokens. The JWT-everywhere pattern looks clean until you need to revoke a token mid-incident and discover you can't. Sessions revoke instantly because the server controls them.
Cookie hardening. Every session cookie gets these flags, no exceptions:
res.cookie("session", sessionId, {
httpOnly: true, // blocks document.cookie access
secure: true, // HTTPS only
sameSite: "lax", // CSRF mitigation; use "strict" if you can
maxAge: 60 * 60 * 24 * 7 * 1000, // 7 days
path: "/",
});
OAuth flows that matter. In 2026, only Authorization Code + PKCE is acceptable for first-party apps. Implicit flow is deprecated. If your auth library still defaults to Implicit, replace the library.
A .env file in your repo always loses, even with .gitignore. It leaks via Docker layer caching, CI logs, IDE backups, and the new engineer who runs git add . on day one. The fix is a managed vault and an SDK fetch at runtime.
| Tool | Best for | Pricing |
|---|---|---|
| Doppler | Best dev experience, instant team setup | Free tier, $19/seat/month for teams |
| Infisical | Open-source, self-host option | Free self-hosted, $0.50/secret/month cloud |
| AWS Secrets Manager | AWS-native, IAM-bound rotation | $0.40 per secret per month |
Doppler wins for most startups: install once, run doppler run -- npm start, secrets land as environment variables at process start without ever touching disk. Infisical is the right pick if your security team needs the source code on-prem. AWS Secrets Manager is the answer when you're already deep in AWS and want IAM-controlled rotation for RDS passwords and API keys.
Whichever you pick, three rules:
gitleaks or trufflehog to find them. Trufflehog scans for 800+ secret types.The OWASP Top 10 is the floor every Node.js app should clear. Here's the 2026 mapping with concrete defenses:
| OWASP Risk | Node Defense | Tool / API |
|---|---|---|
| Broken Access Control | Server-side authz check on every route, not just UI hide | Better-Auth, Casbin |
| Cryptographic Failures | Use built-in node:crypto, never roll your own | crypto.scrypt, crypto.timingSafeEqual |
| Injection | Parameterized queries + Zod validation at boundary | Drizzle, Zod, express-mongo-sanitize |
| Insecure Design | Threat-model auth and billing paths before coding | OWASP Threat Dragon |
| Security Misconfiguration | Helmet defaults + custom CSP | helmet@8 |
| Vulnerable Components | npm audit + Socket + Dependabot | Snyk, Socket |
| Identification & Auth Failures | Modern session library, MFA, rate-limited auth routes | Lucia, Auth.js, Better-Auth |
| Software & Data Integrity | Lockfile + signed packages + ignore-scripts | npm ci, lockfile-lint |
| Logging & Monitoring Failures | Structured logs, no secrets, alert on auth anomalies | Pino, Sentry |
| SSRF | Allowlist outbound URLs, validate user-supplied URLs | ssrf-req-filter |
If you're prepping for SOC 2, this table maps almost one-to-one onto the technical controls auditors ask about. We wrote up the full SOC 2 audit prep playbook here.
req.headers['x-forwarded-for'] without a proxy allowlist. Spoofable. Set app.set("trust proxy", 1) only if you control the proxy chain.crypto.createHmac with === for signature comparison. Timing-attack vulnerable. Use crypto.timingSafeEqual(a, b).multer with limits.fileSize or, better, sign a direct-to-S3 URL.If you're a two-founder pre-revenue team running a marketing site on Vercel, you don't need a managed secrets vault yet. Use Vercel's built-in environment variable UI, ship Helmet defaults, and add Zod to one form. The full playbook is for the moment you have real users and a payment processor.
The ROI cliff is at three points: your first paying customer, your first hire, and your first SOC 2 conversation. Each one of those should trigger a security review.
Every engineer on Cadence is AI-native by default, vetted on Cursor, Claude Code, and Copilot fluency before they unlock bookings on the platform. Senior tier ($1,500/week) is the right pick for security hardening rollouts: dependency audit, Helmet plus Zod plus rate limit defaults, secrets migration to a vault, and an OWASP Top 10 sweep. Most teams ship the full playbook in one to two weeks of senior time.
The 12,800-engineer pool means you can usually start within 24 hours, with a 48-hour free trial that costs nothing if the fit is wrong. If your security checklist has been "we'll get to it" for six months, book a senior engineer and have it audited next week.
Not sure where to start? Run your current setup through Ship-or-Skip for an honest grade on dependency hygiene, runtime defaults, and auth posture, then book a senior engineer for the parts that score below the bar.
engines.node in package.json to >=22.11.0 <23.0.0. Add Volta or fnm so every dev runs the same version.npm ci. Run npm config set ignore-scripts true everywhere. Add Socket or Snyk to your PR checks. Run npm audit signatures.helmet, zod, and express-rate-limit. Wire Helmet at the app root. Add a Zod schema to every public route. Rate-limit auth and password-reset routes at 5 requests per 15 minutes per IP.express-mongo-sanitize and validate shape with Zod. Replace any user-input merge with Object.create(null) + Object.assign.httpOnly + secure + sameSite=lax. Move any service-to-service JWTs to short expiry (15 minutes) with rotation.gitleaks against your git history and rotate everything it finds. Delete .env from any image build.npm audit, rotate long-lived secrets, and check for Node LTS transitions.Yes if you're on Express 5 with Helmet, Zod validation, a modern session library, and rate limiting. Express 4 reached end-of-life in 2025 and stopped receiving security patches. If you're on Express 4, the upgrade to v5 is a breakable but not painful migration; budget half a day per app.
Sessions for web apps. JWTs only for stateless service-to-service calls or short-lived API tokens. Sessions are easier to revoke during an incident, which is the property that matters when something breaks. JWTs sound stateless until you need a deny-list to revoke them, at which point you've reinvented sessions with extra steps.
Run Socket on every PR. It scans for install scripts, network calls, file system access, and typosquat patterns at the package level, not just CVE-known vulnerabilities. Pair with npm audit signatures to verify provenance and npm config set ignore-scripts true to neutralize most install-time payloads.
Possible but rarely worth it. Node's standard library covers HTTP, crypto, and streams well. You still need Zod or equivalent at the API boundary, and writing your own auth code is the highest-risk move you can make. The right move is a small, scrutinized dependency set, not zero dependencies.
Three commands, in order. Add Helmet to your Express root (app.use(helmet())). Set npm config set ignore-scripts true on your CI runners. Rotate any secret that has ever been in a .env file committed to git history. Those three changes block more attack surface than any other afternoon you'll spend this quarter.