The OWASP Top 10 2025 list reorganized web application security, elevating supply chain failures to A03 and folding server-side request forgery (SSRF) into broken access control. For Node.js developers, these shifts demand concrete code changes, not just policy documents. This tutorial walks you through 12 implementation steps that address every category on the 2025 list using real Express.js patterns, tested npm packages, and working code that runs on Node.js 22 LTS. You will start from a blank Express project and finish with a hardened API that passes an OWASP-aligned security review.

OWASP data shows 3.73% of tested applications contain at least one broken access control weakness, making it the single most common finding in 2025. Injection, now A05, still carries the highest CVE density of any category. Supply chain failures, the renamed A03, capture the 2024 wave of malicious packages and build-pipeline compromises that npm’s security team flagged as a structural problem. This tutorial addresses all ten categories in Node.js-specific terms with runnable code, specific package versions, and pitfall explanations that apply to Express, Fastify, and NestJS alike.

Prerequisites and Version Requirements

Before running any code in this tutorial, confirm your environment matches the following versions. Using older runtimes or packages will expose you to known CVEs that this guide specifically works around.

RequirementMinimum versionWhy it matters
Node.js22.x LTSIncludes built-in permission model, updated crypto defaults, native fetch
npm10.xSupports npm audit --audit-level=high and lockfile v3
express4.21.xPatches path traversal edge cases present in 4.18
helmet8.xShips Content-Security-Policy defaults for modern browsers by default
express-validator7.xFixes ReDoS from CVE-2024-47764
express-rate-limit7.xStores state correctly in clustered Node.js deployments
bcrypt5.xBindings rebuilt for OpenSSL 3.x included in Node.js 22
jsonwebtoken9.xCloses algorithm-confusion CVEs from 8.x series
pino9.xStructured JSON logging with built-in redaction support

Start by initializing a fresh project. All 12 steps build on this base:

mkdir owasp-hardened-api && cd owasp-hardened-api
npm init -y
npm install [email protected] helmet@8 cors express-rate-limit@7 \
  express-validator@7 bcrypt@5 jsonwebtoken@9 \
  mongoose@8 pino@9 pino-http@10 redis ipaddr.js
npm install --save-dev nodemon jest supertest

What Changed in the OWASP Top 10 2025 List

The 2025 OWASP Top 10 differs from the widely cited 2021 edition in three areas that directly affect Node.js architecture decisions. Understanding what moved and why helps you prioritize remediation effort rather than treating all ten items as equally urgent.

The most significant structural change is the elevation of supply chain failures to A03. In 2021, this category was “Vulnerable and Outdated Components” (A06), a narrow framing that covered old packages with known CVEs. The 2025 name change signals a broader concern: compromised build pipelines, malicious packages published under typosquatted names, and unsigned artifacts delivered through legitimate channels. The npm ecosystem saw high-profile supply chain incidents in 2024 that pushed OWASP to promote this category from sixth to third.

The second major change is the folding of SSRF into Broken Access Control (A01). SSRF was a standalone A10 category in 2021. In 2025, OWASP merged it into A01 because both vulnerabilities share a root cause: the server trusts a caller-controlled value without verifying that the caller has permission to use it. An attacker who supplies a URL pointing to an internal metadata service exploits the same missing authorization logic as an attacker who supplies another user’s object ID.

Rank2021 name2025 nameKey change for Node.js
A01Broken Access ControlBroken Access ControlNow includes SSRF; IDOR remains the dominant finding at 3.73% of apps
A02Cryptographic FailuresSecurity MisconfigurationClimbed to second; missing headers, open CORS, default credentials
A03InjectionSoftware Supply Chain FailuresNew category; malicious npm packages, build tampering, unsigned artifacts
A04Insecure DesignCryptographic FailuresMoved to fourth; weak JWT secrets, MD5 usage still common
A05Security MisconfigurationInjectionHighest CVE count per category; SQLi, NoSQLi, XSS all map here
A06Vulnerable ComponentsInsecure DesignMissing threat models; no input-length bounds in API design
A07Auth FailuresAuthentication FailuresRenamed; weak passwords, missing MFA, no account lockout
A08Data Integrity FailuresSoftware or Data Integrity FailuresUnsigned npm packages, tampered build artifacts, insecure deserialization
A09Logging FailuresSecurity Logging and Alerting FailuresUnchanged priority; most Node.js apps still log insufficiently
A10SSRFMishandling of Exceptional ConditionsSSRF folded into A01; this now covers unhandled errors, fail-open logic

Step 1: Fix Broken Access Control with Resource Ownership Checks (A01)

Broken access control is the most frequently found vulnerability in production applications, appearing in 3.73% of tested apps according to OWASP’s 2025 dataset. In Node.js REST APIs, it almost always surfaces as an Insecure Direct Object Reference (IDOR): a request like GET /api/orders/42 returns the order regardless of who owns it, because the developer checked authentication (is the user logged in?) but skipped authorization (does this user own order 42?).

The fix requires exactly one additional constraint in every database query that accepts a caller-supplied identifier: bind the query to the authenticated session’s user ID. The resource should only be returned if both the ID matches and the user matches. This pattern eliminates IDOR across every route that retrieves, updates, or deletes user-owned resources.

// middleware/requireAuth.js
import jwt from 'jsonwebtoken';

export function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  if (typeof authHeader !== 'string') return res.sendStatus(401);
  const token = authHeader.split(' ')[1];
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],  // explicit allowlist prevents algorithm confusion
      maxAge: '15m'
    });
    next();
  } catch {
    res.sendStatus(401);
  }
}

// routes/orders.js — vulnerable version (never use this pattern)
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findById(req.params.id); // IDOR: no ownership check
  res.json(order);
});

// routes/orders.js — fixed version
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findOne({
    _id: req.params.id,
    userId: req.user.sub    // ownership check binds resource to authenticated session
  });
  if (!order) return res.sendStatus(404); // same 404 for missing and unauthorized
  res.json(order);
});

Returning 403 for ownership failures leaks information: the attacker learns the resource exists and belongs to someone else. Return 404 consistently so enumeration attempts receive the same response regardless of whether the object exists or belongs to another user. Apply this pattern to every route that accepts an identifier from the URL, query string, or request body.

For SSRF prevention within A01, add a URL validator before any outbound fetch that uses a caller-supplied URL. Block private IP ranges, loopback addresses, cloud metadata endpoints (169.254.169.254 for AWS/Azure/GCP), and non-HTTPS schemes before making the request:

// utils/safeFetch.js
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';

export async function safeFetch(userUrl) {
  const u = new URL(userUrl);                        // throws on malformed input
  if (u.protocol !== 'https:') {
    throw new Error('Only HTTPS URLs allowed');
  }

  const { address } = await dns.lookup(u.hostname);
  const ip = ipaddr.parse(address);

  if (ip.range() !== 'unicast') {
    throw new Error('Request to private or reserved IP blocked');
  }

  if (address === '169.254.169.254' || address === '::1' || address === '127.0.0.1') {
    throw new Error('Request to metadata endpoint or loopback blocked');
  }

  return fetch(u.toString(), {
    signal: AbortSignal.timeout(5000),
    redirect: 'error'    // prevent redirect-based SSRF bypass
  });
}

Step 2: Set Security Headers to Address Security Misconfiguration (A02)

Security Misconfiguration climbed to A02 in 2025 because deployment automation made it easy to spin up servers quickly and harder to notice when defaults expose attack surface. Missing HTTP security headers, permissive CORS policies, and default credentials are the three most common misconfigurations in Node.js applications that reach production. The helmet package (version 8.x) addresses the header layer in a single middleware call.

Helmet 8.x sets a sensible Content-Security-Policy by default, something earlier versions did not do. Install it as the first middleware so every route inherits the headers before any business logic runs. Missing helmet from a single route because it was registered before the middleware is a common oversight that a security scanner will flag immediately.

// app.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';

const app = express();

// A02: security headers must be the first middleware
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: []
    }
  },
  crossOriginEmbedderPolicy: false  // set true only for apps that require cross-origin isolation
}));

// A02: CORS must be narrow — never use origin: '*' with credentials
app.use(cors({
  origin: (origin, callback) => {
    const allowed = (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean);
    if (!origin || allowed.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// A02: remove Express version fingerprint
app.disable('x-powered-by');

After deploying, verify headers using curl -I https://yourdomain.com and confirm the response includes Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, and Content-Security-Policy. Use the OWASP Injection Prevention Cheat Sheet to verify your CSP directive list against known bypass techniques.

Step 3: Audit and Lock Dependencies for Supply Chain Failures (A03)

Software Supply Chain Failures is the new A03 category in 2025, reflecting the structural shift attackers made toward compromising build pipelines and dependency trees rather than targeting individual application code. In the Node.js ecosystem, this manifests as malicious packages published under typosquatted names, compromised maintainer accounts that push backdoored patch releases, and CI/CD pipelines that pull unsigned or unverified artifacts.

The minimum baseline for A03 compliance in a Node.js project covers four controls. The first is committing package-lock.json to version control and installing with npm ci in CI pipelines instead of npm install. The npm ci command requires a lockfile and ignores the version ranges in package.json, ensuring every CI build uses exactly the versions that passed your last audit rather than floating to the newest patch release.

The second control is running npm audit automatically on every pull request and on a daily schedule even without new commits. A package you approved last Monday can have a critical CVE disclosed on Thursday. Block merges when the severity reaches high or critical:

// package.json — add these scripts
{
  "scripts": {
    "audit:prod": "npm audit --omit=dev --audit-level=high",
    "audit:all": "npm audit --audit-level=moderate",
    "ci": "npm ci && npm run audit:prod && npm test"
  }
}

// .npmrc — enforce exact versions and block postinstall scripts from unknown packages
save-exact=true
ignore-scripts=false  // only set true if you have verified all packages

The third control uses Node.js 22’s built-in Permission Model to constrain what a compromised dependency can do at runtime. Even if a malicious package lands in your dependency tree, it cannot exfiltrate files or spawn processes outside the explicit allow-list:

# Dockerfile or process manager start command
# restrict filesystem and network access using Node.js 22 permission model
node --experimental-permission \
     --allow-fs-read=/var/www/app \
     --allow-fs-write=/var/www/app/tmp \
     --allow-net=0.0.0.0:3000 \
     server.js

The fourth control is manual review of any new production dependency before adding it. Check the package’s GitHub repository for commit activity in the past 90 days, compare the download count against the star count for anomalies (a package with 2M weekly downloads and 12 stars is unusual), and run npm pack --dry-run to inspect what files the package would install before executing the install. Also read the Node.js Foundation’s official security best practices for additional supply chain guidance.

Step 4: Enforce Cryptographic Standards to Prevent Failures (A04)

Cryptographic Failures sits at A04 in 2025. Most Node.js developers know not to use MD5 for passwords, but subtle misconfigurations in JWT libraries and TLS settings remain widespread in production code. The three most common cryptographic failures in Node.js are: weak or randomly-chosen JWT signing secrets under 32 bytes, missing algorithm constraints in jwt.verify that allow algorithm confusion attacks, and transmitting sensitive data over HTTP in staging environments that accidentally reach users.

For JWT, the critical control is the algorithms allow-list in jwt.verify. Without it, a caller can submit a token signed with the none algorithm and bypass signature verification entirely. This vulnerability drove critical CVEs in older jsonwebtoken 8.x versions. The fix requires two characters of configuration:

// utils/crypto.js
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';

const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET must be at least 32 characters — generate with: node -e "require(\'crypto\').randomBytes(32).toString(\'hex\')"');
}

export function signToken(payload) {
  return jwt.sign(payload, JWT_SECRET, {
    algorithm: 'HS256',
    expiresIn: '15m',
    issuer: process.env.JWT_ISSUER,
    audience: process.env.JWT_AUDIENCE
  });
}

export function verifyToken(token) {
  return jwt.verify(token, JWT_SECRET, {
    algorithms: ['HS256'],         // critical: explicit allowlist prevents algorithm confusion
    issuer: process.env.JWT_ISSUER,
    audience: process.env.JWT_AUDIENCE
  });
}

// bcrypt cost factor 12 is the current minimum recommendation
export async function hashPassword(plaintext) {
  if (typeof plaintext !== 'string') throw new Error('Password must be a string');
  if (plaintext.length > 72) throw new Error('Password exceeds bcrypt 72-byte limit');
  return bcrypt.hash(plaintext, 12);
}

export async function verifyPassword(plaintext, hash) {
  if (typeof plaintext !== 'string' || typeof hash !== 'string') return false;
  return bcrypt.compare(plaintext, hash);
}

Generate the JWT secret using Node.js built-in crypto, never a human-memorable phrase: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or Doppler) and inject via environment variable at runtime. Never commit secrets to version control, even in a private repository.

Step 5: Prevent Injection in SQL, MongoDB, and Output Contexts (A05)

Injection is A05 in 2025 and carries the highest CVE count of any OWASP category. In Node.js, injection takes four common forms: SQL injection through raw query strings, NoSQL injection through operator objects passed as JSON (MongoDB’s $ne, $gt, $where), cross-site scripting through unsanitized HTML in server-rendered templates, and command injection through child_process.exec with untrusted input. All four share the same defense: treat every piece of external data as untrusted and parse it into a typed, bounded structure before using it in any query, command, or output context.

Use express-validator 7.x (which fixes the ReDoS from CVE-2024-47764) to validate and sanitize input at the route layer. Pair it with parameterized database queries to prevent injection at the data layer:

// utils/validators.js
import { body, param, validationResult } from 'express-validator';
import { pool } from '../db/postgres.js';
import User from '../models/User.js';

// A05: SQL injection — parameterized queries, validated input types
app.get('/api/users/:id',
  param('id').isInt({ min: 1, max: 2147483647 }).toInt(),
  requireAuth,
  async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

    // value passed as separate argument, never interpolated into the query string
    const result = await pool.query(
      'SELECT id, email, created_at FROM users WHERE id = $1',
      [req.params.id]
    );
    if (result.rows.length === 0) return res.sendStatus(404);
    res.json(result.rows[0]);
  }
);

// A05: NoSQL injection — coerce to string, reject operator objects
const loginValidators = [
  body('email').isEmail().normalizeEmail().isString(),
  body('password').isString().isLength({ min: 12, max: 72 }),
  body('email').not().isObject(),    // reject { "$ne": null } style operator attacks
  body('password').not().isObject()
];

app.post('/api/login', loginValidators, async (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

  const { email, password } = req.body;
  const user = await User.findOne({ email: String(email) }).select('+password');
  if (!user || !(await verifyPassword(password, user.password))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  res.json({ token: signToken({ sub: user._id.toString() }) });
});

For XSS prevention, the primary defense is output encoding at the template layer, not input sanitization on the way in. If your Node.js backend renders HTML using Handlebars, EJS, or Pug, confirm that auto-escaping is enabled (it is by default in all three) and never call the triple-brace {{{}}} syntax (Handlebars) or != operator (EJS) with user-supplied data. For APIs returning JSON to a separate frontend, the CSP header from Step 2 provides the browser-side defense.

Step 6: Apply Threat Modeling Principles from Insecure Design (A06)

Insecure Design (A06) is the only OWASP category that cannot be fixed by patching or adding a library after the fact. It captures missing security requirements at the design stage: no rate limit on password reset endpoints, no maximum password length that prevents bcrypt truncation issues, no account lockout policy, no thought given to what happens when a payment token expires mid-checkout. In Node.js projects, insecure design appears most often as unbounded request body sizes, unlimited file upload volumes, and missing business logic guards that attackers discover through fuzzing.

// A06: limit payload sizes — Express has no default limit
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, limit: '100kb' }));

// A06: file upload with strict size and type limits
import multer from 'multer';

const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024,   // 5 MB maximum per file
    files: 1,
    fields: 10
  },
  fileFilter: (_req, file, cb) => {
    const allowed = ['image/jpeg', 'image/png', 'image/webp'];
    cb(null, allowed.includes(file.mimetype));
  }
});

app.post('/api/upload', requireAuth, upload.single('avatar'), asyncHandler(async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file or invalid type' });
  // process req.file.buffer — it is in memory, not on disk
  res.json({ message: 'Uploaded' });
}));

// A06: account lockout using Redis counters
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const LOCKOUT_THRESHOLD = 5;
const LOCKOUT_TTL_SECONDS = 900;  // 15 minutes

async function checkLockout(email) {
  const key = `lockout:${email}`;
  const attempts = parseInt(await redis.get(key) || '0', 10);
  if (attempts >= LOCKOUT_THRESHOLD) {
    throw Object.assign(new Error('Account temporarily locked'), { status: 429, isOperational: true });
  }
}

async function recordFailure(email) {
  const key = `lockout:${email}`;
  const current = await redis.incr(key);
  if (current === 1) await redis.expire(key, LOCKOUT_TTL_SECONDS);
}

async function clearLockout(email) {
  await redis.del(`lockout:${email}`);
}

Step 7: Harden Authentication Against Failures (A07)

Authentication Failures (A07) in Node.js applications cluster into four patterns: weak password policies, tokens that never expire, missing brute-force protection, and insecure password reset flows. The bcrypt cost factor and JWT expiration settings were covered in Step 4. This step focuses on the password reset flow, which teams frequently implement incorrectly because it is tested less thoroughly than the login path.

Password reset tokens must be cryptographically random, single-use, short-lived, and stored as hashes in the database. Storing a plaintext reset token is equivalent to storing a plaintext password: a database breach lets an attacker reset any account with a pending request without knowing the current password.

// routes/auth.js — secure password reset implementation
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';

// always return 200 to prevent email enumeration
app.post('/api/auth/forgot-password',
  body('email').isEmail().normalizeEmail(),
  asyncHandler(async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

    res.json({ message: 'If that email is registered, a reset link was sent.' });

    const user = await User.findOne({ email: req.body.email });
    if (!user) return;  // respond identically whether user exists or not

    const token = crypto.randomBytes(32).toString('hex');   // 256 bits of entropy
    const tokenHash = await bcrypt.hash(token, 10);         // store hash, not plaintext

    user.resetTokenHash = tokenHash;
    user.resetTokenExpires = new Date(Date.now() + 15 * 60 * 1000);  // 15-minute window
    await user.save();

    await sendPasswordResetEmail(user.email, token);  // send raw token in the email link
  })
);

app.post('/api/auth/reset-password',
  body('token').isString().isLength({ min: 64, max: 64 }),
  body('newPassword').isString().isLength({ min: 12, max: 72 }),
  asyncHandler(async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

    const { token, newPassword } = req.body;
    const candidates = await User.find({
      resetTokenExpires: { $gt: new Date() }
    }).select('+resetTokenHash +resetTokenExpires');

    let matchedUser = null;
    for (const candidate of candidates) {
      if (await bcrypt.compare(token, candidate.resetTokenHash)) {
        matchedUser = candidate;
        break;
      }
    }

    if (!matchedUser) {
      return res.status(400).json({ error: 'Invalid or expired token' });
    }

    matchedUser.password = await hashPassword(newPassword);
    matchedUser.resetTokenHash = undefined;      // invalidate: one-time use
    matchedUser.resetTokenExpires = undefined;
    await matchedUser.save();

    res.json({ message: 'Password updated successfully' });
  })
);

Step 8: Validate Build and Data Integrity (A08)

Software and Data Integrity Failures (A08) covers two separate risks that share a root cause: insufficient verification of external data before trusting it. The first is unsigned or unverified build artifacts, which the supply chain controls in Step 3 address. The second is processing attacker-controlled serialized data without validating its structure, which leads to prototype pollution, unsafe deserialization, and injection through structured formats like JSON that callers can craft to contain unexpected keys or deeply nested objects.

For webhook endpoints that receive payloads from third-party services, always verify the cryptographic signature before processing. GitHub, Stripe, and most major platforms include an HMAC-SHA256 signature in a request header. The comparison must use a timing-safe method to prevent length oracle attacks:

// middleware/verifyWebhook.js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhookSignature(secret) {
  return (req, res, next) => {
    const sigHeader = req.headers['x-hub-signature-256'];
    if (!sigHeader) return res.sendStatus(401);

    const expected = 'sha256=' + createHmac('sha256', secret)
      .update(req.rawBody)   // requires express.raw() middleware for this route
      .digest('hex');

    if (sigHeader.length !== expected.length) return res.sendStatus(401);

    // timing-safe comparison — never use === for HMAC values
    if (!timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))) {
      return res.sendStatus(401);
    }
    next();
  };
}

// routes/webhooks.js
import Joi from 'joi';

const githubWebhookSchema = Joi.object({
  action: Joi.string().valid('created', 'deleted', 'edited').required(),
  repository: Joi.object({
    name: Joi.string().max(100).required(),
    full_name: Joi.string().max(200).required()
  }).required()
}).options({ stripUnknown: true });  // reject extra keys

app.post('/webhooks/github',
  express.raw({ type: 'application/json' }),
  verifyWebhookSignature(process.env.GITHUB_WEBHOOK_SECRET),
  asyncHandler((req, res) => {
    const body = JSON.parse(req.rawBody);
    const { error, value } = githubWebhookSchema.validate(body);
    if (error) return res.status(400).json({ error: error.message });
    processGitHubEvent(value);
    res.sendStatus(200);
  })
);

Step 9: Implement Security Logging and Alerting (A09)

Security Logging and Alerting Failures (A09) remains a persistent OWASP category because logs are the only control that proves all other controls are working. Without structured security event logging, a breach discovered weeks after the fact cannot be scoped, and regulators under GDPR and CCPA treat “we do not know what data was accessed” as a separate violation from the breach itself. GDPR Article 33 requires notifying supervisory authorities within 72 hours of discovering a personal data breach, which is impossible without logs that capture what was accessed and by whom.

The pino library (version 9.x) produces structured JSON logs at high throughput with built-in redaction for sensitive fields. Use pino-http for request-level logging and build a dedicated security event logger for authentication and authorization events. The key discipline is logging the outcome of every sensitive operation with enough context to reconstruct an attacker’s session from logs alone:

// utils/logger.js
import pino from 'pino';
import pinoHttp from 'pino-http';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  redact: {
    paths: [
      'req.headers.authorization',
      'req.body.password',
      'req.body.token',
      'req.body.newPassword',
      'req.body.resetToken'
    ],
    censor: '[REDACTED]'
  }
});

export const httpLogger = pinoHttp({ logger });

export function logSecurityEvent(req, event, details = {}) {
  logger.info({
    type: 'security_event',
    event,
    userId: req.user?.sub || null,
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    path: req.path,
    method: req.method,
    ...details
  });
}

// app.js — register httpLogger before routes
app.use(httpLogger);

// example usage in a route
app.post('/api/login', loginValidators, asyncHandler(async (req, res) => {
  try {
    await checkLockout(req.body.email);
    const user = await authenticateUser(req.body.email, req.body.password);
    await clearLockout(req.body.email);
    logSecurityEvent(req, 'login_success', { userId: user._id });
    res.json({ token: signToken({ sub: user._id.toString() }) });
  } catch (err) {
    await recordFailure(req.body.email).catch(() => {});
    logSecurityEvent(req, 'login_failure', { reason: err.message });
    res.status(401).json({ error: 'Invalid credentials' });
  }
}));

Forward logs to an external sink immediately. Do not rely on local files on the application server: if an attacker gains write access to the server, they can overwrite or truncate log files. Use a managed logging service, a SIEM, or at minimum an append-only S3 bucket with S3 Object Lock enabled. Set automated alerts on these security events: more than 10 login failures per IP in 60 seconds, any unauthorized_admin_access event, and any 500-level response from authentication endpoints. The Express security best practices page also covers logging recommendations in the context of production deployments.

Step 10: Handle Errors Safely to Prevent Exceptional Conditions (A10)

Mishandling of Exceptional Conditions (A10) replaced standalone SSRF in the 2025 OWASP list and captures a failure mode most developers recognize when they see it in production logs: unhandled promise rejections that hang or crash the server, error responses that leak stack traces and internal paths to end users, and fail-open authorization logic where an error in a permission check grants access rather than denying it.

Express 4.x does not automatically catch async errors thrown inside route handlers. An unhandled rejection will hang the request indefinitely or (in newer Node.js runtimes) terminate the process. The asyncHandler wrapper utility prevents this for every async route, and the global error handler ensures consistent, non-leaking error responses:

// utils/asyncHandler.js
export const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// use on every async route
app.get('/api/data', requireAuth, asyncHandler(async (req, res) => {
  const data = await db.fetchSensitiveData(req.user.sub);
  res.json(data);
}));

// middleware/errorHandler.js
export function globalErrorHandler(err, req, res, _next) {
  // A09: log full error details internally
  logger.error({ err, userId: req.user?.sub, path: req.path }, 'Unhandled error');

  // A10: never expose stack traces to clients
  const status = err.status || err.statusCode || 500;

  if (err.isOperational) {
    // operational errors are safe to describe (e.g., validation failure, lockout)
    return res.status(status).json({ error: err.message });
  }

  // programmer errors get a generic message — no implementation details
  res.status(500).json({ error: 'An internal error occurred' });
}

// server.js — handle process-level errors
process.on('unhandledRejection', (reason) => {
  logger.fatal({ reason }, 'Unhandled promise rejection');
  process.exit(1);  // exit fast and let the process manager restart cleanly
});

process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'Uncaught exception');
  process.exit(1);
});

Step 11: Add Rate Limiting Across All Sensitive Endpoints

Rate limiting addresses multiple OWASP 2025 categories simultaneously: it defends A07 (Authentication Failures) against credential stuffing and brute-force, reduces the SSRF amplification vector within A01, and limits the damage from A10-style request flooding through expensive endpoints. It is one of the highest return-on-investment controls in Node.js APIs because a single npm package handles all three scenarios.

The express-rate-limit package (7.x) supports Redis-backed storage through the rate-limit-redis package, which is required for any deployment that runs more than one Node.js process. An in-memory store silently gives each process its own independent counter, meaning a 10-request limit with 4 workers actually allows 40 requests before any process sees a 429 response.

// middleware/rateLimiter.js
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

const redisStore = new RedisStore({
  sendCommand: (...args) => redisClient.sendCommand(args)
});

// strict limit for authentication endpoints
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,          // 15-minute window
  limit: 10,                           // 10 requests per IP per window
  standardHeaders: 'draft-7',          // RateLimit headers per RFC 9110
  legacyHeaders: false,
  store: redisStore,
  handler: (req, res) => {
    logSecurityEvent(req, 'rate_limit_exceeded', { endpoint: req.path });
    res.status(429).json({
      error: 'Too many attempts. Please try again in 15 minutes.'
    });
  }
});

// broader limit for general API traffic
export const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 100,
  standardHeaders: 'draft-7',
  legacyHeaders: false,
  store: redisStore
});

// app.js — trust proxy must match your actual infrastructure
app.set('trust proxy', 1);  // 1 = trust exactly one reverse proxy hop; never use true

app.use('/api/auth', authLimiter);
app.use('/api', apiLimiter);

Setting trust proxy to true allows a remote attacker to spoof their IP address by injecting a fake X-Forwarded-For header, bypassing your rate limits. Set it to the exact number of proxy hops in front of your Node.js server: typically 1 for a single load balancer, 2 for a CDN plus load balancer. Confirm the setting works by checking req.ip in a test endpoint and verifying it reflects the actual client address, not the load balancer’s internal IP.

Step 12: Run a Security Audit and Automate Ongoing Verification

The final step converts a one-time hardening effort into a sustained practice. Security debt accumulates between releases: a dependency secure today develops a critical CVE next month; a JWT secret in production for three years should have been rotated six months ago; a new developer adds a raw query without noticing the parameterization requirement. Automation closes these gaps without requiring a security champion to manually review every change.

Add a GitHub Actions security workflow that runs on every push, every pull request, and on a daily schedule. The daily schedule is critical because it catches CVEs disclosed between releases without waiting for the next PR. The npm audit documentation explains how to interpret the output and suppress false positives for dev-only packages:

# .github/workflows/security.yml
name: Security

on:
  push:
    branches: [main, develop]
  pull_request:
  schedule:
    - cron: '0 6 * * *'    # daily scan at 06:00 UTC

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install with lockfile
        run: npm ci

      - name: Audit production dependencies
        run: npm audit --omit=dev --audit-level=high

      - name: Run security integration tests
        run: npm test -- --testPathPattern=security
        env:
          NODE_ENV: test
          JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }}

      - name: Scan for hardcoded secrets
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          extra_args: --only-verified

Also run OWASP ZAP (now available as ZAP by Checkmarx) against a staging instance as a post-deploy step on every release. ZAP’s automated scan covers the most common injection, authentication, and misconfiguration findings in under five minutes for a typical REST API. Block production promotion if ZAP reports any High-severity findings. The scan report serves as evidence for compliance audits under SOC 2, ISO 27001, and PCI DSS frameworks that require periodic application security testing.

Common Pitfalls and How to Avoid Them

These are the implementation mistakes most teams make the first time they apply OWASP guidance to a Node.js project. Each is subtle enough to pass a code review and serious enough to cause a production security incident.

PitfallWhy it is dangerousFix
Using == null to check for token presenceEmpty string passes the check and triggers undefined behavior in jwt.verifyUse typeof token === 'string' && token.length > 0
Setting app.set('trust proxy', true)Any caller can spoof X-Forwarded-For, bypassing rate limits and IP loggingSet to the exact number of proxy hops (typically 1)
Returning 403 on IDOR ownership check failureReveals the resource exists; enables object ID enumerationReturn 404 for both missing and unauthorized resources
Using Math.random() for reset tokensNot cryptographically random; can be predicted under some conditionsAlways use crypto.randomBytes(32) for security tokens
Logging request body globally without redactionExposes passwords, tokens, and PII in log files accessible to many team membersUse pino’s redact option to censor sensitive fields before writing
Omitting algorithms in jwt.verifyAllows algorithm confusion; a none-signed token bypasses verificationAlways pass { algorithms: ['HS256'] } or whichever algorithm you signed with
Running npm install in CI pipelinesAllows minor/patch updates that could introduce malicious code between runsUse npm ci, which requires a lockfile and ignores package.json ranges
Missing asyncHandler on async route handlersUnhandled rejection hangs requests in Express 4 or crashes the processWrap every async handler with asyncHandler()
Passing passwords longer than 72 bytes to bcryptbcrypt silently truncates; two different 100-byte passwords hash identically if first 72 bytes matchReject or pre-hash passwords over 72 bytes before bcrypt processing
Storing password reset tokens in plaintextDatabase breach allows immediate account takeover for all pending resetsStore the bcrypt hash of the token; send plaintext only in the email link

Troubleshooting Guide

1. Helmet blocks legitimate requests to external scripts

Symptom: Browser console shows Refused to load script because it violates the Content Security Policy for a trusted CDN or analytics script.
Cause: Helmet 8.x defaults to scriptSrc: ["'self'"], blocking any script not served from the same origin.
Fix: Add the specific external domain to the scriptSrc directive. Never add 'unsafe-inline' for scripts; use nonce-based CSP if you need inline scripts. For Google Analytics: add 'https://www.googletagmanager.com' to both scriptSrc and connectSrc.

2. Rate limiter does not work behind a load balancer

Symptom: Attackers exceed the configured limit without receiving 429 responses. Logs show all requests arriving from the same IP address (the load balancer’s internal IP).
Cause: app.set('trust proxy', ...) is missing or set incorrectly, so req.ip resolves to the load balancer IP rather than the real client IP.
Fix: Set app.set('trust proxy', 1) for a single proxy hop. Verify with a test endpoint that logs req.ip and confirm it shows the actual client address.

3. JWT verify throws “invalid algorithm”

Symptom: JsonWebTokenError: invalid algorithm on tokens that were signed by the same application moments earlier.
Cause: The algorithms array in jwt.verify does not include the algorithm string used in jwt.sign. The strings must match exactly: 'HS256', not 'hmacsha256' or 'sha256'.
Fix: Ensure both the sign call and the verify call specify the same algorithm string. If you recently migrated from RS256 to HS256, old tokens will fail verification by design; issue new tokens after updating the algorithm.

4. npm audit fails CI on devDependency vulnerabilities

Symptom: The CI pipeline fails on npm audit --audit-level=high for packages only used in tests or the build toolchain.
Cause: npm audit without flags scans all dependencies including development ones.
Fix: Use npm audit --omit=dev --audit-level=high in production CI pipelines. Run a separate, lower-threshold audit for development dependencies. If a devDependency runs in a build step that produces production artifacts, treat it as a production risk and upgrade it.

5. express-validator errors pass through to the database layer

Symptom: Invalid input reaches the database query and causes a crash or unexpected behavior instead of returning a 400 response.
Cause: The validationResult(req) check exists in the route but is missing a return statement before the error response, so execution continues into the database query regardless.
Fix: Always return after sending the error: if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); The missing return is the most common express-validator mistake in code reviews.

6. CORS preflight fails for PUT and DELETE requests

Symptom: The browser sends an OPTIONS preflight request that returns 404 or 405; subsequent PUT and DELETE calls are blocked with a CORS error.
Cause: The CORS middleware is registered after some route handlers, or the methods option does not include the required HTTP verbs.
Fix: Register app.use(cors(...)) before all route definitions. Add methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] to the CORS config and return status 204 for OPTIONS requests.

7. bcrypt.compare always returns false for correct passwords

Symptom: Login always fails with “Invalid credentials” even when the user enters the correct password they just set during registration.
Cause: The password was hashed during registration after normalization (trimming whitespace, Unicode normalization) but compared at login without the same normalization. Alternatively, the hash stored in the database was truncated by a column width limit shorter than bcrypt’s 60-character output.
Fix: Apply consistent normalization at both registration and login. Verify the stored hash starts with $2b$ and is exactly 60 characters. Set the database column type to at least VARCHAR(60).

8. Unhandled promise rejections crash the production server

Symptom: The server exits unexpectedly with UnhandledPromiseRejectionWarning or goes unresponsive after a database query times out.
Cause: An async route handler threw an error that was not passed to Express’s next() function and not caught by the global error handler.
Fix: Wrap every async route with the asyncHandler utility from Step 10 and add both process.on('unhandledRejection') and process.on('uncaughtException') handlers in your server entry point. If you upgrade to Express 5, async error propagation is handled automatically without the wrapper.

Advanced Tips for Production Hardening

These techniques go beyond the baseline 12 steps and address vectors that appear in penetration test reports for mature Node.js applications that have already applied the standard controls.

Add timing pads to authentication responses. bcrypt runs only when a user is found, so login responses for nonexistent accounts arrive faster than for existing ones with incorrect passwords. This timing difference leaks user existence even when both return the same 401 response body. Fix it by always running bcrypt, whether the user exists or not:

// compute once at process startup, reuse for all "user not found" cases
const TIMING_PAD_HASH = await bcrypt.hash('constant-timing-placeholder', 12);

async function authenticateUser(email, password) {
  const user = await User.findOne({ email }).select('+password');
  const hash = user ? user.password : TIMING_PAD_HASH;
  const match = await bcrypt.compare(password, hash);  // always runs bcrypt
  if (!user || !match) throw Object.assign(new Error('Invalid credentials'), { status: 401, isOperational: true });
  return user;
}

Run Node.js with the built-in Permission Model in production. Node.js 22 introduced a stable permission model that restricts filesystem access, child process spawning, and network connections to explicit allow-lists. Even if a compromised dependency lands in your tree via a supply chain attack, it cannot exfiltrate files or spawn reverse shells outside the allow-list.

Audit ORM escape-hatch methods before each release. Mongoose, Sequelize, and Prisma all have raw query methods that bypass their parameterization. Search your codebase for $where, mapReduce, eval (Mongoose), Sequelize’s literal(), and Prisma’s $queryRawUnsafe(). Every call site must be reviewed manually because linters cannot determine whether the input to those methods is trusted.

Use subresource integrity (SRI) for CDN-hosted assets. If your Node.js server renders HTML that loads JavaScript from a CDN, a CDN compromise delivers malicious code to every visitor. Generate the integrity attribute hash with openssl dgst -sha384 -binary <file> | openssl base64 -A and add integrity="sha384-..." to your <script> tags. The browser will refuse to execute the file if the hash does not match.

Scope admin functionality to a separate Express router mounted on a separate port. If your API serves both public endpoints and admin operations, an access control misconfiguration on a single route can expose admin functionality to public callers. Mount admin routes on an internal port bound only to localhost and accessible through a separate internal load balancer, so network-layer controls reinforce application-layer authorization.

Complete Project Structure

The complete hardened Express application structure after applying all 12 steps looks like this. Each directory and file maps to one or more OWASP 2025 categories:

owasp-hardened-api/
├── server.js                   # entry point; process error handlers (A10)
├── app.js                      # Express setup and middleware chain
├── middleware/
│   ├── requireAuth.js          # JWT verification with algorithm allowlist (A01, A07)
│   ├── rateLimiter.js          # Redis-backed rate limiting (A07, A10)
│   ├── errorHandler.js         # global error handler + asyncHandler utility (A10)
│   ├── verifyWebhook.js        # HMAC-SHA256 webhook signature check (A08)
│   └── securityHeaders.js      # helmet + narrow CORS configuration (A02)
├── routes/
│   ├── auth.js                 # login, register, password reset (A07, A04)
│   ├── orders.js               # resource ownership checks, IDOR prevention (A01)
│   └── webhooks.js             # webhook ingestion with signature verification (A08)
├── utils/
│   ├── crypto.js               # JWT sign/verify, bcrypt hash/compare (A04)
│   ├── logger.js               # pino structured security event logging (A09)
│   ├── safeFetch.js            # SSRF-safe outbound HTTP utility (A01)
│   ├── asyncHandler.js         # async error propagation wrapper (A10)
│   └── validators.js           # express-validator schemas for all routes (A05)
├── models/
│   └── User.js                 # Mongoose schema with typed fields and indexes
├── db/
│   ├── postgres.js             # parameterized query connection pool (A05)
│   └── redis.js                # Redis client for sessions and rate limits
├── package.json                # exact version pins, audit:ci script
├── package-lock.json           # committed to version control (A03)
└── .github/
    └── workflows/
        └── security.yml        # daily npm audit + TruffleHog secret scan (A03)

More from Shattered.io

For the authoritative category definitions and testing methodology, see the official OWASP Top 10 2025 list.

Frequently Asked Questions

Is the OWASP Top 10 2025 list the current official version?

Yes. OWASP published the 2025 Top 10 as the successor to the 2021 edition. The 2025 list reorganized several categories based on updated testing data and renamed “Vulnerable and Outdated Components” to “Software Supply Chain Failures” to reflect the broader threat landscape seen in 2023 and 2024. You can review the full methodology and CWE mappings at owasp.org/Top10.

Which OWASP category causes the most breaches in Node.js applications?

Broken Access Control (A01) is the most frequently found vulnerability across all tested applications. OWASP’s 2025 dataset shows 3.73% of applications contain at least one CWE in this category, making it the most common finding by a wide margin. In Node.js REST APIs, the IDOR pattern (returning data for any ID without checking ownership) accounts for the majority of these findings. Injection (A05) carries the highest CVE count but is easier to test for and remediate with parameterized queries and validation libraries.

Does helmet.js provide complete protection against Security Misconfiguration?

No. Helmet covers the HTTP header portion of Security Misconfiguration (A02). It does not address default credentials left unchanged after deployment, unnecessary features enabled in your Express application, missing TLS termination configuration, or open cloud storage buckets. Treat helmet as one layer of a misconfiguration defense. Review your deployment environment, cloud IAM policies, database connection credentials, and environment variable handling separately from the application code.

How often should I run npm audit?

Run it on every pull request and on a daily schedule, even in weeks with no new commits. The Node.js Foundation’s security guidance recommends daily dependency scans in production because a package approved on Monday may receive a critical CVE disclosure on Thursday. Automate both the PR check and the daily schedule through your CI/CD pipeline. Manual audits are not sufficient because they depend on a developer remembering to run them.

Can I use Express 5 instead of Express 4 for this tutorial?

Yes. Express 5 (released in 2024) handles unhandled async rejections automatically inside route handlers, which eliminates the need for the asyncHandler wrapper from Step 10. All other steps in this guide apply identically to Express 5. If you are starting a new project in 2026, Express 5 is the recommended choice. For existing Express 4 applications, the migration guide on the Express website covers the breaking changes, which are minimal for typical REST API projects.

What is the difference between authentication and authorization in OWASP terms?

Authentication (A07) answers “who are you?” and verifies identity through passwords, tokens, or certificates. Authorization (A01, Broken Access Control) answers “what are you allowed to do?” and enforces resource-level permissions after identity is confirmed. Both checks must pass for every request that touches user-owned data. The most common IDOR vulnerability in Node.js APIs passes authentication (the JWT is valid) but skips authorization (the route never checks whether the token’s user ID matches the resource owner).

Does this guide apply to Fastify or NestJS?

Yes, with different package names. Fastify has direct equivalents: @fastify/helmet, @fastify/rate-limit, and @fastify/cors. NestJS uses decorators and guards for access control but runs on Express or Fastify underneath. The OWASP categories, cryptographic requirements, audit automation, and structured logging discipline are framework-agnostic. Every step in this guide has a direct equivalent in both Fastify and NestJS. The specific registration patterns and package names differ, but the security logic is identical.

How do I test my Node.js API against the OWASP Top 10?

Use a layered approach combining automated scanning with targeted integration tests. Run OWASP ZAP against your staging environment to cover injection, misconfiguration, and authentication vectors automatically. Write integration tests using Supertest that specifically verify authorization boundaries: confirm that a request authenticated as user A receives a 404 (not a 403 or a 200) when it requests user B’s resources. For supply chain coverage, enable Dependabot or Renovate in your repository and run daily npm audit scans. These three layers together provide meaningful ongoing coverage without requiring a dedicated security team member on every release cycle.