{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"node-js-security-checklist-for-2026-fym1k","url":"https://zyvop.com/node-js-security-checklist-for-2026-fym1k","title":"Node.js Security Checklist for 2026","subtitle":"A practical, copy-paste-friendly checklist covering security headers, rate limiting, password hashing, dependency audits, and supply-chain safety for Node.js apps.","tldr":"Many Node.js security incidents start with missing basics. This checklist covers a supported runtime, Helmet headers, rate limiting, Argon2id password hashing, dependency audits, and supply-chain defenses like release cooldowns and install-script allowlists.","keywords":["Web Security","security","npm","supply chain","node.js","Tutorial"],"entities":["Ankit Singh","Software Engineer","Web Security","security","npm","supply chain","node.js","Tutorial","ZyVOP"],"keyTakeaways":["Many Node.js security incidents don't start with an exotic zero-day.","They start with missing basics: an unpatched runtime, no limits on a login endpoint, passwords stored with a fast hash, or a dependency that was compromised a few hours before your CI pulled it.","This checklist starts with your runtime, then walks through the five areas that give you the most protection for the least effort."],"headings":["1. Start with a Supported Node.js Version","2. Security Headers with Helmet","3. Rate Limiting","4. Password Hashing","5. Dependency Audits","6. Supply-Chain Safety","Add a release cooldown","Control install scripts","Check where packages come from","Limit the blast radius","If you publish packages","Be careful with auto-updates","A Few More Basics Worth Checking","The Full Checklist","Wrapping Up"],"outboundLinks":[],"contentText":"Many Node.js security incidents don't start with an exotic zero-day. They start with missing basics: an unpatched runtime, no limits on a login endpoint, passwords stored with a fast hash, or a dependency that was compromised a few hours before your CI pulled it. This checklist starts with your runtime, then walks through the five areas that give you the most protection for the least effort. Each section explains the why, shows the code or config, and ends with items you can tick off. The examples use Express, but the ideas apply to Fastify, Hono, NestJS, and anything else. 1. Start with a Supported Node.js Version Everything else is built on the runtime, so check it first. Node.js 20 reached end of life on April 30, 2026, which means it receives no fixes at all, and end-of-life versions are affected whenever a security release ships. As of late 2026, the supported lines are: Node.js 22: maintenance LTS, supported until April 30, 2027 Node.js 24: maintenance LTS since October 2026, supported until April 30, 2028 Node.js 26: Active LTS since October 28, 2026, supported until April 30, 2029 Odd-numbered releases such as Node.js 25 were short-lived and not meant for production; Node.js 25 reached end of life on June 1, 2026. From Node.js 27 on, the project moves to one major release a year, and every release becomes LTS. Security releases land regularly across every supported line (October 2026 brought one that covered the 22, 24, and 26 lines), so a pinned version is only as safe as your last rebuild. Rebuild and redeploy your images on a schedule instead of only when you ship features. Run a supported LTS line in production: Node.js 26 for new projects, or Node.js 24 until you're ready to move If you're on Node.js 22, plan your upgrade before April 30, 2027 Pin your Docker base image to a major line (for example node:24-alpine), never latest, so a rebuild can't silently jump a major version Put the Node.js end-of-life date for your version in your team calendar Subscribe to the Node.js security announcements and patch promptly when a security release lands Rebuild and redeploy images regularly so security releases actually reach production 2. Security Headers with Helmet Browsers have many built-in protections, but they only switch on when your server asks for them through HTTP response headers. Helmet is middleware that sets a sensible group of those headers in one line. import express from \"express\"; import helmet from \"helmet\"; const app = express(); app.use(helmet());With the defaults, Helmet adds headers such as Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, and Cross-Origin-Opener-Policy, and it removes the X-Powered-By header that advertises Express. The part that needs your attention is the Content Security Policy (CSP), the header that controls which scripts, styles, and images a page may load. The default policy is strict: it only allows scripts and most other resources from your own origin and blocks inline event handlers, so it may break inline scripts or third-party assets. Tune it rather than switching it off: app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: [\"'self'\"], scriptSrc: [\"'self'\"], imgSrc: [\"'self'\", \"data:\", \"https://cdn.example.com\"], objectSrc: [\"'none'\"], }, }, referrerPolicy: { policy: \"strict-origin-when-cross-origin\" }, }) );A few things to keep in mind: Roll out CSP in report-only mode first. Set reportOnly: true inside contentSecurityPolicy, watch the browser console and your violation reports, then switch to enforcing once nothing legitimate is blocked. Prefer nonces over 'unsafe-inline'. If you really need an inline script, generate a per-request nonce and add it to scriptSrc, rather than loosening the policy for every script on the page. Helmet only sets headers. It doesn't fix injection bugs, broken authorization, or bad cookies. Cross-origin assets may break. Helmet's default Cross-Origin-Resource-Policy is same-origin. If other sites legitimately load your images or files, relax it for those routes. Mind HSTS. Helmet sends Strict-Transport-Security by default, and it covers subdomains too, so make sure the site and its subdomains work over HTTPS before you deploy it. If a proxy or CDN already sets the header, avoid sending conflicting values from the app. Fastify users can get the same result with the @fastify/helmet plugin. While you're touching headers and cookies, two related basics belong on the same list: Configure CORS with an explicit allowlist of origins. Never combine a wildcard origin with credentials. Set session cookies with httpOnly, secure, and sameSite so scripts can't read them and browsers don't send them where they shouldn't. app.use(helmet()) is the first middleware A CSP is defined, rolled out in report-only mode first, and tested in a real browser with the console open for violations CORS uses an allowlist, not * Cookies use httpOnly, secure, and sameSite You've scanned your site with a header checker after deploying 3. Rate Limiting Without limits, every endpoint is an invitation: credential stuffing on /login, SMS and email abuse on /forgot-password, scraping on /search, or plain old resource exhaustion. A rate limiter caps how often a client can hit a route. import { rateLimit } from \"express-rate-limit\"; // Tell Express how many proxies sit in front of the app app.set(\"trust proxy\", 1); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes limit: 100, standardHeaders: \"draft-8\", legacyHeaders: false, }); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 5, skipSuccessfulRequests: true, }); // Second limiter keyed on the account being attacked, not the client IP. // It must run after the body parser so req.body.email is available. const accountLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, skipSuccessfulRequests: true, keyGenerator: (req) =&gt; `login:${String(req.body.email).trim().toLowerCase()}`, skip: (req) =&gt; typeof req.body?.email !== \"string\", }); app.use(\"/api\", apiLimiter); // check limits before doing any work app.use(express.json()); // then parse request bodies app.post(\"/api/login\", loginLimiter, accountLimiter, loginHandler);(This matches express-rate-limit v8. Older versions call the limit option max and may not support draft-8 headers.) The code is the easy part. These details decide whether the limiter actually works: Get the client IP right. Behind a reverse proxy, load balancer, or tunnel, every request appears to come from the proxy unless trust proxy is configured. The result is one shared bucket for all users. Set it to match the number of proxies in front of your app, then confirm it with a temporary test route that returns req.ip: it should show your own address, not the proxy's. Setting it too loosely lets attackers spoof their IP with a forged X-Forwarded-For header. Use a shared store when you run more than one instance. The default store lives in each process's memory, so ten instances means ten separate limits. Use a Redis-backed store (for example rate-limit-redis) so the count is shared. Limit by account, not just by IP. An attacker with many IPs can still hammer one user's login, which is what the accountLimiter above is for. Hard lockouts let an attacker lock a victim out on purpose, so prefer increasing delays or extra verification (such as a CAPTCHA) over permanent blocks. Watch out for IPv6. One client can control a huge range of IPv6 addresses, so limiting a single address is easy to dodge. Recent express-rate-limit versions group IPv6 addresses by subnet by default; keep that behavior unless you have a reason to change it. Be stricter on expensive or sensitive routes. Login, signup, password reset, OTP verification, and anything that sends email or SMS deserve tighter limits than a read-only endpoint. Limit more than request counts. Cap request body size (Express's default of 100kb is a reasonable ceiling, so lower it where you can rather than raising it), keep server timeouts sensible, and paginate list endpoints. Add a layer at the edge. A CDN, WAF, or reverse proxy can absorb abusive traffic before it reaches Node.js. Treat the in-app limiter as the last line of defense, not the only one. Return 429 Too Many Requests with a Retry-After header so well-behaved clients know when to come back. A global limiter protects the whole API Login, password reset, signup, and OTP routes have stricter limits Login attempts are also limited per account trust proxy is set correctly and req.ip is verified in production A shared store is used if you run multiple instances Request body size and server timeouts are capped 4. Password Hashing If your database leaks, the way you hashed passwords decides whether attackers get a list of usable passwords or a pile of useless strings. The rule is simple: use a slow, memory-hard, salted algorithm designed for passwords, never a general-purpose hash. Never use plain text, MD5, SHA-1, or a single round of SHA-256. They're built to be fast, which is exactly what an attacker with a GPU wants. Recommended order of preference: Argon2id, the current recommendation from OWASP scrypt, built into Node.js. Its defaults are weaker than OWASP's minimum (N=2^17, r=8, p=1), so set the cost explicitly and raise maxmem to fit it bcrypt, still acceptable for existing systems, with a cost factor of at least 10 Here's Argon2id using the popular argon2 package, which handles salting and encoding for you: import * as argon2 from \"argon2\"; const OPTIONS = { type: argon2.argon2id, memoryCost: 19456, // KiB, about 19 MiB timeCost: 2, parallelism: 1, }; // Used to keep response time similar when the user doesn't exist // (top-level await works in ES modules; otherwise create it in an async setup function) const DUMMY_HASH = await argon2.hash(\"not-a-real-password\", OPTIONS); export async function hashPassword(password: string) { return argon2.hash(password, OPTIONS); } export async function login(email: string, password: string) { const user = await findUserByEmail(email); const ok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password); if (!user || !ok) { throw new Error(\"Invalid email or password\"); // same message either way } // Upgrade old hashes as your settings improve if (argon2.needsRehash(user.passwordHash, OPTIONS)) { await updatePasswordHash(user.id, await argon2.hash(password, OPTIONS)); } return user; }The memory, time, and parallelism numbers above match OWASP's minimum guidance for Argon2id at the time of writing. Treat them as a floor: benchmark on your own hardware and raise them as far as your login latency allows, and check the OWASP Password Storage Cheat Sheet for the latest values. OWASP also lists equivalent trade-offs, such as 46 MiB of memory with a single iteration, if you'd rather spend memory than CPU time. As a rule of thumb, one hash should take well under a second on your production hardware. A few practical notes: Node.js now has Argon2 built in. Since v24.7.0, crypto.argon2() and crypto.argon2Sync() are part of node:crypto. That's attractive if you want zero native dependencies, but you're responsible for generating the salt, storing the parameters alongside the hash, and comparing results in constant time. For most apps, a well-maintained library is less error-prone. verify() throws on a malformed hash. It returns false for a wrong password, but an exception means the stored value is broken or something internal failed. Treat that as a server error, not a failed login. argon2 and bcrypt are native modules. If one fails to load because install scripts are blocked (see the supply-chain section), approve or rebuild just that package. bcrypt has a 72-byte limit. Anything longer is silently truncated, so cap password length or pre-hash carefully if you stay on bcrypt. Cap the maximum password length (say, 128 characters) so nobody can send a megabyte-long password to burn your CPU. Favor length over complexity rules. Long passphrases and screening against known-breached passwords beat forcing symbols and digits. Don't leak whether an account exists. Use the same error message and similar timing for \"unknown email\" and \"wrong password\". Offer more than a password. Add MFA, and consider passkeys (WebAuthn) for new projects. Never log passwords, even failed attempts, and redact them from error reports. Passwords are hashed with Argon2id, scrypt, or bcrypt, never a fast hash Parameters are benchmarked and stored with the hash so they can be upgraded Old hashes are re-hashed on successful login when settings change Maximum password length is capped Login errors are generic and timing is similar for unknown users MFA is available, at least for admin accounts 5. Dependency Audits A typical Node.js app pulls in hundreds of transitive packages, and some of them will have known vulnerabilities at any given moment. Audits turn that risk into a list you can act on. # Check production dependencies and fail on high severity or above npm audit --omit=dev --audit-level=high # Verify registry signatures and provenance attestations npm audit signaturesnpm audit compares your lockfile against a database of known advisories. npm audit signatures goes a step further and checks that the packages you installed carry valid registry signatures, and verifies provenance attestations where publishers provide them. Both belong in CI: # .github/workflows/security.yml (excerpt) permissions: contents: read jobs: audit: runs-on: ubuntu-latest steps: # Pin actions to a full commit SHA and note the version in a comment - uses: actions/checkout@&lt;full-commit-sha&gt; # vX.Y.Z - uses: actions/setup-node@&lt;full-commit-sha&gt; # vX.Y.Z with: node-version: 24 - run: npm ci - run: npm audit --omit=dev --audit-level=high - run: npm audit signaturesReplace each &lt;full-commit-sha&gt; with the commit SHA of the release you've reviewed; Dependabot can keep pinned SHAs up to date for you. Some habits make audits far more useful: Install from the lockfile. Use npm ci in CI and Docker builds so you get exactly what was reviewed, and commit your lockfile. Triage instead of panicking. Many advisories affect code paths you never call, or dev-only tools. Focus on high and critical issues in production dependencies that your app actually reaches. Fix at the right level. Try npm audit fix first, review what it changed, and use overrides in package.json when a deep dependency is stuck on a vulnerable version and the parent hasn't caught up. Automate updates, but review them. Dependabot or Renovate keep you current. Group low-risk updates, and read the changelog for anything touching authentication, crypto, or HTTP handling. Delete what you don't use. Every unused dependency is attack surface with no benefit. Tools like depcheck or knip find them. Use a second opinion. Scanners such as OSV-Scanner, Snyk, or Socket catch things from different data sources, including suspicious package behavior that has no CVE yet. One important limit: audits only know about disclosed problems. A freshly poisoned package has no advisory yet, which brings us to the next section. Lockfile is committed and CI uses npm ci npm audit runs on every pull request and on a weekly schedule npm audit signatures runs in CI Automated update PRs are reviewed, not blindly merged Unused dependencies are removed regularly 6. Supply-Chain Safety Supply-chain attacks target you through the code you trust. The Shai-Hulud worm in late 2025 showed how quickly a compromise can spread from package to package. In March 2026, attackers took over an axios maintainer's account through social engineering and published malicious versions of a package downloaded millions of times a week; those versions pulled in an extra dependency that downloaded remote access trojans. Two assumptions make these attacks work: that you install the bad version quickly, and that your package manager runs install scripts (npm 12 now blocks them by default, but older versions and other tools may not). Breaking both assumptions stops many of these attacks, though not all of them (more on the limits below). Add a release cooldown Compromised versions are usually spotted and pulled within hours or days. If your tooling refuses to install anything younger than a week, you let everyone else find the problem first. # .npmrc (min-release-age needs npm 11.10.0 or newer) # Don't install versions published in the last 7 days min-release-age=7 # Save exact versions instead of ^ranges save-exact=true # Treat \"engines\" mismatches as errors instead of warnings engine-strict=true# pnpm-workspace.yaml minimumReleaseAge: 10080 # minutes (7 days)The units differ between tools: npm uses days, pnpm uses minutes, and Bun uses seconds. The npm 12 defaults GitHub announced cover install scripts, git dependencies, and remote URLs, so a cooldown is still something you opt into yourself. Option names and defaults keep evolving, so confirm them against the documentation for the version you run. Cooldowns of a few days to a week are common. Longer is safer but delays updates, and a cooldown can also delay a genuine security fix. When you need a patch right away, review that release yourself and add a per-package exception (pnpm has minimumReleaseAgeExclude) instead of switching the cooldown off. Control install scripts Lifecycle scripts like postinstall run code on your laptop or CI machine the moment you install, before you've looked at anything. That's why attackers love them, and it's how the Shai-Hulud worm spread. npm 12 and later (released in July 2026) blocks them by default. Install scripts from dependencies, native node-gyp builds, and prepare scripts from git dependencies only run if you allow them. You review packages once, and the allowlist is saved in package.json so the whole team and CI share it: # List dependencies that have install scripts npm approve-scripts --allow-scripts-pending # Allow the ones you trust, block the rest npm approve-scripts sharp esbuild npm deny-scripts some-packageCommit the updated package.json, and treat a pull request that adds a package to the allowlist as a real review, not a rubber stamp. To prepare, upgrade to npm 11.16 or later, where the same checks appear as warnings. Pin the npm version in CI too, so a surprise upgrade doesn't break your pipeline. On npm 11 and older, turn scripts off globally and allow them case by case: # .npmrc on npm 11 and older ignore-scripts=true # Block dependencies fetched straight from git (npm 11.10+, and the default in npm 12) allow-git=nonenpm ci --ignore-scripts npm rebuild sharp esbuild --ignore-scripts=falsepnpm doesn't run dependency scripts unless you allow them. List the trusted packages in onlyBuiltDependencies (or let pnpm approve-builds update the list for you): # pnpm-workspace.yaml onlyBuiltDependencies: - sharp - esbuildOne caution: ignore-scripts=true is a blanket switch that also skips scripts you've approved, so on npm 12 rely on the allowlist instead of combining the two. These settings are strong, but they aren't a complete defense. Blocking install scripts closes one execution path, but a malicious package can still run when your app imports it or when you execute its command (for example through npx). A cooldown won't help if a compromise stays hidden for weeks, and an allowlist is only as good as the review behind it. Treat each of these as one layer among several. Check where packages come from Run npm audit signatures to confirm what you installed matches what the registry signed. Prefer packages that publish with provenance, which links a release to the source repository and build workflow that produced it. Watch out for look-alike names (typosquats) and packages with a sudden ownership change. Avoid dependencies pulled straight from git URLs or remote tarballs. npm 12 blocks both by default, and on npm 11.10 and later you can set allow-git=none yourself. Git dependencies skip registry review, and a git dependency's own .npmrc can override the Git executable even when ignore-scripts is on. Before adding a package, check how it's maintained, how many dependencies it drags in, and whether it really does something you can't write in a few lines. Limit the blast radius Assume a bad package will eventually get through, and make sure it finds very little to steal. Keep secrets away from install steps. Don't run npm install in an environment that holds production credentials. Build in one stage without secrets, and inject runtime secrets only into the final container. Scope CI tokens tightly. Use short-lived, least-privilege credentials, and avoid long-lived tokens with publish rights. Pin GitHub Actions to commit SHAs and give workflows read-only permissions by default. Tags can be moved, and in 2025 the popular tj-actions/changed-files action was compromised exactly that way. Dependabot's github-actions ecosystem can keep pinned SHAs up to date. Run containers as a non-root user with a read-only filesystem where you can. Consider Node's permission model (--permission) as an extra layer to restrict file and network access. Treat it as defense in depth, not a sandbox for untrusted code. Know your exposure. When news of a compromised package breaks, you should be able to answer \"do we use it, and which version?\" in minutes. npm ls &lt;package&gt; and your lockfile give you that answer. If you publish packages Turn on 2FA, preferably with passkeys or hardware keys, for every maintainer. Publish from CI using npm trusted publishing (OIDC) instead of a long-lived token, so releases carry provenance and there's no token to steal. Once it's set up, turn off token-based publishing for the package. Plan for long-lived tokens going away. GitHub has announced that 2FA-bypass access tokens will lose the ability to publish directly (expected around January 2027), so move automated publishing to trusted publishing, or to staged publishing with a human approval step. Keep your own dependency list small and your install scripts empty. Be careful with auto-updates Bots that open pull requests for every new release are convenient, but they can also hand you a poisoned version within minutes of its publication. Configure Renovate or Dependabot to wait before proposing brand-new releases, and keep a human in the loop for merges. A release cooldown is configured for your package manager Install scripts are blocked by default (the npm 12 allowlist, or ignore-scripts on older versions), with a short, reviewed allowlist Git-sourced and remote-URL dependencies are blocked or reviewed The npm version is pinned in CI Builds run without production secrets CI tokens are scoped and short-lived; publishing uses trusted publishing GitHub Actions are pinned to commit SHAs with read-only default permissions You can list every project that uses a given package within minutes A Few More Basics Worth Checking These didn't get their own section, but they belong on every checklist: Validate all input at the boundary with a schema library such as Zod. Use parameterized queries or an ORM; never build SQL or NoSQL queries from raw strings. Keep secrets out of images and logs. Prefer file-based secrets over environment variables for real credentials. Serve everything over HTTPS and redirect HTTP. Return generic error messages to clients, and keep stack traces in your logs only. Log security events such as failed logins and permission errors, and alert on spikes. The Full Checklist Runtime Production runs a supported Node.js LTS line (not Node.js 20) Docker images are pinned to a major version and rebuilt regularly Headers and browser protections Helmet is enabled and the CSP is tested CORS uses an allowlist; cookies are httpOnly, secure, and sameSite Rate limiting Global and per-route limits are in place, with stricter limits on auth routes trust proxy is correct and a shared store is used across instances Passwords Argon2id (or scrypt/bcrypt) with benchmarked parameters Generic login errors, capped password length, MFA available Dependency audits npm ci, npm audit, and npm audit signatures run in CI Update PRs are reviewed; unused packages are removed Supply chain Release cooldown configured; install scripts blocked except for a reviewed allowlist Secrets are kept out of install and build steps Trusted publishing and 2FA for anything you publish CI actions pinned to commit SHAs Wrapping Up You don't need to do everything in a single afternoon. A good order is: update your runtime, add Helmet and rate limiting (an hour of work), confirm your password hashing, then wire audits and a release cooldown into CI and your .npmrc. Together those steps close some of the most common gaps attackers rely on. Security is also never finished. Dependencies change, new advisories appear, and your own code grows. Put a recurring reminder in your calendar, run through this list every quarter, and fix whatever has drifted.","contentHash":"sha256:123ee74fd80f07f23aeee1c834dc2ecfe38d404b48f8aa1bc7d793ae113e4e0c","authorName":"Ankit Singh","authorUrl":"https://zyvop.com/author/ankit","authorSameAs":[],"category":"Tutorial","tags":["Web Security","security","npm","supply chain","node.js"],"audience":"Software engineers and developers building applications with Tutorial","tone":"Instructional, practical, code-first","readingTimeMinutes":15,"wordCount":3962,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-10-31T11:09:17.600Z","updatedAt":"2026-10-31T11:09:17.600Z","canonicalUrl":"https://zyvop.com/node-js-security-checklist-for-2026-fym1k"}