close

DEV Community

Stefan
Stefan

Posted on • Originally published at codereviewlab.com

OWASP Secure Coding Checklist for Node Express APIs 2026

OWASP Secure Coding Checklist for Node Express APIs 2026

Most Express APIs that get popped in 2026 do not fall to some exotic zero-day. They fall to a route handler that string-concatenates req.body into a query, an auth middleware that decodes a JWT without verifying it, and a stack trace returned straight to the client. The OWASP API Security Top 10 has barely changed in shape because the same mistakes keep shipping. This checklist is the version we actually run during code review: each item maps to an OWASP category, and each one has a pass/fail test you can apply to a pull request without arguing about it.

How Common OWASP Risks Exploit Express APIs

Picture an internal reporting endpoint. It takes a tenant ID from the URL, a filter from the body, and a bearer token from the Authorization header. The developer trusts all three. Here is roughly what ships:

// VULNERABLE — do not deploy
const jwt = require('jsonwebtoken');

app.post('/api/reports/:tenantId', (req, res) => {
  const token = req.headers.authorization.split(' ')[1];
  const claims = jwt.decode(token); // decode, not verify

  const sql = `SELECT * FROM reports
               WHERE tenant_id = '${req.params.tenantId}'
               AND status = '${req.body.status}'`;

  db.query(sql, (err, rows) => {
    res.json({ user: claims.sub, rows });
  });
});
Enter fullscreen mode Exit fullscreen mode

Three OWASP risks live in those nine lines. jwt.decode reads the token payload without checking the signature, so an attacker forges any sub and tenantId they want. That is API2:2023 Broken Authentication and, because the forged claim crosses tenants, API1:2023 Broken Object Level Authorization. The req.params.tenantId and req.body.status go into the SQL string raw, which is API8:2023 Security Misconfiguration territory in the OWASP API list and classic SQL injection underneath.

The attack chains cleanly. An attacker registers a low-privilege account, captures their own valid token, then crafts a request where status is ' OR '1'='1 and tenantId is a victim's ID pulled from a forged JWT. The decode-only check waves the forged token through, the OR clause defeats the status filter, and the response leaks another tenant's reports. No single bug here is novel. The damage comes from trusting three inputs that should each have been verified independently.

It is worth tracing how each of these maps to a real-world advisory, because the abstraction can make them feel theoretical. The alg: none bypass class is tracked back to CVE-2015-9235 in the original jsonwebtoken library and keeps reappearing whenever a verifier trusts the token's self-declared algorithm. SQL injection through unparameterized template strings is the oldest entry on this list and still the highest-impact one when it lands on a multi-tenant table. BOLA, the quietest of the three, rarely gets a CVE of its own because it is an application-logic flaw, not a library bug, which is exactly why scanners miss it and humans have to catch it in review.

Hardening Express Routes: The Core Fix Pattern

The fix is not a library. It is a discipline: verify the token, validate the body against a schema, parameterize the query, and authorize the object after you know who the caller is. The same route, rebuilt:

const jwt = require('jsonwebtoken');
const { z } = require('zod');

const ReportFilter = z.object({
  status: z.enum(['open', 'closed', 'pending']),
});

function requireAuth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'unauthorized' });

  try {
    // verify checks signature, expiry, issuer, audience — decode checks nothing
    req.claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'], // pin the algorithm to block alg:none and HS/RS confusion
      audience: 'reports-api',
      issuer: 'https://auth.internal',
    });
    next();
  } catch {
    return res.status(401).json({ error: 'unauthorized' });
  }
}

app.post('/api/reports/:tenantId', requireAuth, (req, res, next) => {
  const parsed = ReportFilter.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ error: 'invalid filter' });

  // Authorize the object: the caller's token, not the URL, decides tenant access
  if (req.claims.tenantId !== req.params.tenantId) {
    return res.status(403).json({ error: 'forbidden' });
  }

  db.query(
    'SELECT * FROM reports WHERE tenant_id = $1 AND status = $2',
    [req.params.tenantId, parsed.data.status],
    (err, result) => {
      if (err) return next(err); // hand off to the central error handler, do not leak
      res.json({ rows: result.rows });
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

Note the algorithm pin. Leaving algorithms unset is how the alg: none and RS256-to-HS256 confusion attacks still land in 2026; if your verifier accepts whatever the token header claims, an attacker picks the weakest option. The RS256-to-HS256 confusion is the subtle one: when your verifier accepts both, an attacker takes your public RS256 key (which is, by design, public), signs a forged token with it using HS256, and your verifier happily uses that same public key as the HMAC secret. Pinning to a single asymmetric algorithm closes the door.

The object-level check is the part the framework docs skip: validating input does nothing for BOLA if the URL still decides which tenant you read. The token has to win that argument. Walk the data flow in the fixed handler in order. The token is verified before any business logic runs, so req.claims is trustworthy by the time you reach the comparison. The schema parse rejects anything outside the enum before it touches the query, so the second placeholder can only ever hold one of three known strings. The tenant comparison reads from the cryptographically verified claim, not the attacker-controlled URL segment. Only after all three gates pass does the parameterized query run, and the driver sends the values as bound parameters, never as part of the SQL text. Each step assumes the previous one held, which is why the order matters as much as the presence of each check.

This is the baseline the rest of the checklist enforces. Everything below is a specific instance of "verify, validate, parameterize, authorize."

Authentication and Session Checklist Items

Auth is where the highest-severity findings cluster, so the checks are blunt and binary.

  • Verify, never decode. Grep for jwt.decode( in any code path that gates access. It should appear zero times outside of logging or debugging. Pass condition: every auth boundary calls jwt.verify with an explicit algorithms array.
  • Pin the algorithm and validate aud/iss. A token minted for your auth service's admin console should not authenticate against your reports API. Reject tokens whose audience or issuer does not match.
  • Hash passwords with a memory-hard function. Use argon2 (preferred) or bcrypt with a cost factor of at least 12. Plain SHA-256, even salted, fails this item. Never store a reversible secret.
  • Rate-limit credential endpoints separately. Login, password reset, and token refresh need tighter limits than read endpoints. A global limiter that allows 1000 req/min still permits a brute-force run.
  • Expire and rotate. Access tokens short (5–15 minutes), refresh tokens rotated on use, server-side revocation for logout. A 30-day non-revocable access token is a finding.

A minimal login limiter, scoped to the sensitive route only:

const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10, // per IP per window; pair with per-account lockout for credential stuffing
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => res.status(429).json({ error: 'too many attempts' }),
});

app.post('/api/auth/login', loginLimiter, loginHandler);
Enter fullscreen mode Exit fullscreen mode

IP-based limiting alone does not stop a distributed credential-stuffing run, so add a per-account counter that locks after N failures regardless of source IP. The tradeoff there is real: a per-account lockout is itself a denial-of-service vector, because an attacker who knows a username can lock a victim out at will. The usual compromise is an escalating delay plus a CAPTCHA challenge rather than a hard lock, so a legitimate user gets back in after a cooldown while automated runs stall. If you want a wider tour of how these controls fail in real codebases, the breakdown of broken authentication patterns on Code Review Lab walks through the variants we see most in review, including the refresh-token reuse case that bites teams who rotate but never revoke.

Input Validation, Output Encoding, and Dependencies

Validate at the edge, once, with a schema. Scattered if (!req.body.x) checks rot into inconsistency. Centralize.

const { z } = require('zod');

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse({
      body: req.body,
      query: req.query,
      params: req.params,
    });
    if (!result.success) {
      // log the failure server-side, return a generic message to the client
      return res.status(400).json({ error: 'invalid request' });
    }
    req.validated = result.data; // downstream reads validated, never raw req.body
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Once req.validated exists, make it a review rule that handlers read from it and never touch req.body again. That single convention kills a whole class of "we validated the wrong copy" bugs.

A few specifics that matter in 2026:

  • Reject unknown keys. Use .strict() in zod or stripUnknown plus presence: 'required' in Joi. Mass-assignment (API3:2023 Broken Object Property Level Authorization) happens when a client sends { "role": "admin" } and your ORM happily writes it.
  • Do not deserialize untrusted data into live objects. Avoid eval, Function, and node-serialize-style packages on request data. Gadget chains fire from constructors before your validation ever runs.
  • Encode on output, contextually. JSON responses are mostly safe by default, but any server-rendered HTML or templated email needs context-aware encoding. Set Content-Type: application/json explicitly so a browser does not sniff a JSON blob as HTML.

Supply chain is half the battle now. Pin and audit in CI, and fail the build on high-severity advisories:

# .github/workflows/security.yml
- name: Audit dependencies
  run: npm audit --audit-level=high
- name: Verify lockfile is committed and clean
  run: npm ci # fails if package-lock.json is missing or out of sync
Enter fullscreen mode Exit fullscreen mode

npm ci is the quiet hero here: it refuses to run if the lockfile drifts from package.json, which stops a "works on my machine" dependency from sneaking in unpinned. Commit package-lock.json, never .npmrc with a token in it, and treat a new transitive dependency in a PR diff as something to read, not rubber-stamp. The gotcha with npm audit --audit-level=high is advisory noise: a high-severity finding in a dev-only build tool that never runs in production will fail your pipeline and tempt the team to disable the gate entirely. Scope the audit to production dependencies (npm audit --omit=dev) for the blocking gate and run the full audit as a non-blocking report, so a real runtime CVE still stops the build while a prototype-pollution finding in a test runner does not.

Logging, Monitoring, and Error Handling Done Safely

The error handler is where good intentions leak data. A thrown SQL error containing the failing query, or a stack trace pointing at file paths and library versions, hands an attacker a map. Return generic messages outward; keep the detail in structured logs.

const pino = require('pino');

const logger = pino({
  redact: {
    // redact before serialization so secrets never reach disk or your log pipeline
    paths: ['req.headers.authorization', 'req.body.password', '*.token', '*.ssn'],
    censor: '[REDACTED]',
  },
});

// Central error handler — register last, after all routes
app.use((err, req, res, next) => {
  const correlationId = req.id || crypto.randomUUID();
  logger.error({
    correlationId,
    route: req.path,
    method: req.method,
    msg: err.message,
    stack: err.stack, // server-side only, never serialized to the response
  });
  res.status(500).json({ error: 'internal error', correlationId });
});
Enter fullscreen mode Exit fullscreen mode

Return the correlationId to the client so support can find the matching log line without exposing what actually broke. That one ID turns "the API is throwing 500s" into a one-grep investigation.

Three logging rules that survive contact with production:

  • Redact at the logger, not at each call site. Per-call redaction always misses one path. Configure the redaction list centrally so a new endpoint inherits it automatically.
  • Log security events as first-class events. Failed auth, authorization denials, rate-limit trips, and schema-validation failures should emit structured logs with a stable event field you can alert on. A 401 spike from one IP is a signal.
  • Never log the secret you are checking. Logging a rejected token "for debugging" writes a valid credential to disk. The redaction list above exists precisely to stop that reflex.

One production gotcha that pino's redaction does not cover: secrets that arrive nested in places you did not anticipate, like a token embedded in a query string on a redirect URL, or a password echoed inside an error object's cause chain. Redaction paths match structure, so a secret that lands at an unexpected path sails through. The defense is to keep the log payload narrow. Log specific named fields rather than spreading a whole request or error object, because logger.error(err) on a rich error can serialize properties you never inspected. The full set of patterns for secure logging without leaking secrets covers the PII-classification side too, which is where GDPR and your incident-response retention policy start to collide.

API Versioning, Deprecation, and Secure Configuration

Secure defaults are cheap and you should set them in one place. helmet handles most response-header hygiene; CORS you must configure explicitly because the permissive default is a vulnerability.

const helmet = require('helmet');
const cors = require('cors');

app.use(helmet()); // sets HSTS, X-Content-Type-Options, frame guards, and more

app.use(cors({
  origin: ['https://app.internal', 'https://admin.internal'], // allowlist, never '*'
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));

// Versioned routers let you deprecate without breaking live clients
const v1 = require('./routes/v1');
const v2 = require('./routes/v2');

app.use('/api/v2', v2);

// Legacy endpoint kept alive on a sunset clock, with a warning header
app.use('/api/v1', (req, res, next) => {
  res.set('Deprecation', 'true');
  res.set('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT'); // RFC 8594
  next();
}, v1);
Enter fullscreen mode Exit fullscreen mode

origin: '*' with credentials: true is not just wrong, the browser will reject the combination, but teams patch around it by reflecting the request origin, which is worse: it allowlists everyone. Use a static array. The Sunset header (RFC 8594) gives consumers a machine-readable deadline so you can actually delete the insecure v1 handler instead of carrying it forever. Versioning is a security control because it gives you a safe path to remove an endpoint you can no longer defend. Doing it without breaking integrations is a discipline of its own; the lesson on safe API versioning and deprecation covers contract testing and the header conventions clients can rely on.

Note: helmet() defaults are sensible but not complete. If you serve any HTML, set a Content-Security-Policy explicitly rather than trusting the default, which is intentionally loose to avoid breaking apps. A second configuration trap that bites teams behind a load balancer: if you do not set app.set('trust proxy', ...) correctly, req.ip resolves to the proxy address, which silently breaks your IP-based rate limiter and your geolocation logging. Set it to the exact number of proxy hops you control, never to a blanket true, because a permissive trust setting lets a client forge X-Forwarded-For and evade the limiter entirely.

The Complete 2026 Checklist and How to Run It in Review

Copy this into your PR template. Each item is pass/fail and maps to an OWASP API category so a reviewer can cite the control instead of an opinion.

## Auth (API2, API1)
- [ ] jwt.verify with explicit algorithms array; no jwt.decode on auth paths
- [ ] aud and iss validated against expected values
- [ ] Object-level auth checks token claims, not URL/body, for resource access
- [ ] Passwords hashed with argon2 or bcrypt (cost >= 12)
- [ ] Login/reset/refresh endpoints rate-limited + per-account lockout

## Input & Dependencies (API3, A06)
- [ ] All input validated via central schema; handlers read req.validated
- [ ] Unknown keys rejected (.strict / stripUnknown) — blocks mass assignment
- [ ] No eval/Function/unsafe deserialization on request data
- [ ] npm ci in CI; package-lock.json committed
- [ ] npm audit gates the build on production deps

## Logging & Errors (API9, API8)
- [ ] Central error handler returns generic message + correlationId
- [ ] No stack traces or query text in client responses
- [ ] Logger redaction configured centrally (tokens, passwords, PII)
- [ ] Security events (401/403/429/400) emitted as structured logs

## Config & Versioning (API8)
- [ ] helmet enabled; CSP set explicitly if serving HTML
- [ ] CORS origin is a static allowlist, never '*' or reflected
- [ ] trust proxy set to exact hop count behind a load balancer
- [ ] Deprecated routes carry Deprecation + Sunset headers with a real date
Enter fullscreen mode Exit fullscreen mode

Run it as a two-stage gate. At the PR level, the reviewer checks the boxes that the diff touches. A change to an auth route triggers the Auth block; a new endpoint triggers Input and Config. Pre-release, run the whole list against the surface area that changed in the milestone. A sample review comment that keeps the conversation about controls rather than taste:

Blocking: route reads req.body.status directly into the query (API8/SQLi).
Move to req.validated and parameterize ($1/$2). See checklist item
"All input validated via central schema". Happy to pair on the zod schema.
Enter fullscreen mode Exit fullscreen mode

This consolidated list pairs well with the broader secure coding practices checklist, which goes language-by-language beyond Express. If you are formalizing this into a review rubric for a team, the application security engineer skills track maps each control to the judgment calls a reviewer has to make when the checklist and the deadline disagree. You can find the full set of guides on Code Review Lab.

Tomorrow, grep your codebase for jwt.decode( and + req. in the same files as a database call. Those two patterns, side by side, are where your next incident is hiding. Fix the auth boundary first, because everything downstream assumes it already held.

Top comments (0)