Security Best Practices -- OWASP Top 10 for Node.js
Security Best Practices -- OWASP Top 10 for Node.js
LinkedIn Hook
"Your Node.js app has 47 vulnerabilities. You just don't know about them yet."
Most production Node.js applications ship with critical security flaws hiding in plain sight. A forgotten
eval()call. A missinghelmetheader. A login endpoint with no rate limit. A.envfile accidentally committed to GitHub. An npm package that hasn't been updated in three years.The OWASP Top 10 isn't a checklist for paranoid security engineers -- it's the bare minimum every backend developer must understand. Injection attacks still rank #1 in 2026. Broken authentication is everywhere. Sensitive data exposure happens because someone logged a password to stdout "just for debugging".
The good news? Node.js gives you everything you need to defend against all ten categories.
helmetfor headers.express-rate-limitfor brute force. Parameterized queries for SQL injection.npm auditfor vulnerable dependencies.zodfor env var validation. The tools are free -- you just have to use them.In Lesson 8.4, I walk through every OWASP Top 10 category with a Node.js example, the exact attack, and the exact fix.
Read the full lesson -> [link]
#NodeJS #WebSecurity #OWASP #BackendDevelopment #InterviewPrep
What You'll Learn
- The OWASP Top 10 categories and how each one applies to Node.js
- How to prevent SQL/NoSQL injection with parameterized queries
- How to harden HTTP responses with
helmetsecurity headers - How to defend login endpoints from brute force with
express-rate-limit - How to validate environment variables at boot time with
zod - How to scan dependencies for known vulnerabilities with
npm audit, Snyk, and Dependabot - How to apply the principle of least privilege to database users, file systems, and IAM roles
- How to log security events without leaking secrets
The Castle Analogy -- Why Defense in Depth Matters
Imagine a medieval castle. It has a moat, an outer wall, an inner wall, a gatehouse, archers on towers, locked rooms, and a vault in the keep. If an attacker scales the outer wall, the inner wall stops them. If they breach the gate, archers still hit them. If they reach the keep, the vault is locked from inside.
Now imagine a "castle" with one tall wall and nothing else. One ladder. One breach. Game over.
Web applications work the same way. A single security control -- "we use HTTPS" or "we hash passwords" -- is a single wall. Real security is defense in depth: many overlapping controls, so a failure in one layer is caught by the next. Your helmet headers stop XSS payloads the input validator missed. Your rate limiter blocks brute force the strong password policy didn't prevent. Your database user with SELECT-only permissions limits damage when an injection slips through.
The OWASP Top 10 is the blueprint for those overlapping walls.
+---------------------------------------------------------------+
| OWASP TOP 10 (2021/2026 STILL CURRENT) |
+---------------------------------------------------------------+
| |
| A01 Broken Access Control (most common) |
| A02 Cryptographic Failures (formerly "sensitive data") |
| A03 Injection (SQL, NoSQL, command, LDAP) |
| A04 Insecure Design (architecture-level flaws) |
| A05 Security Misconfiguration (defaults, debug, headers) |
| A06 Vulnerable & Outdated Comp. (npm packages, runtime) |
| A07 Identification & Auth Fail. (broken login, sessions) |
| A08 Software & Data Integrity (deserialization, supply) |
| A09 Logging & Monitoring Fail. (no audit trail) |
| A10 Server-Side Request Forgery (SSRF) |
| |
| Plus the legacy XXE category, still relevant for XML APIs. |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). A castle with multiple walls labeled with OWASP categories. Each wall is a different security control: 'helmet headers', 'rate limit', 'parameterized queries', 'JWT verify', 'least privilege DB user'. Arrows showing attackers (amber #ffb020) being stopped at different layers. Node green (#68a063) shields on each wall. White monospace labels."
A03 -- Injection: Parameterized Queries Are Non-Negotiable
Injection happens when user input is concatenated into a query, command, or template that an interpreter will execute. SQL injection is the classic, but Node.js apps are equally vulnerable to NoSQL injection (MongoDB), command injection (child_process.exec), and prototype pollution.
The Vulnerable Pattern (DO NOT SHIP)
// BAD -- string concatenation builds a SQL query from user input
const express = require('express');
const mysql = require('mysql2');
const app = express();
const db = mysql.createConnection({ /* ... */ });
app.get('/users', (req, res) => {
// Attacker sends ?email=' OR '1'='1
// Final query becomes: SELECT * FROM users WHERE email = '' OR '1'='1'
// Returns every user in the database
const query = "SELECT * FROM users WHERE email = '" + req.query.email + "'";
db.query(query, (err, rows) => res.json(rows));
});
The Safe Pattern -- Parameterized Queries
// GOOD -- the driver sends the query and the values separately
// The database engine never confuses data with code
app.get('/users', (req, res) => {
// The ? placeholder is replaced safely by the driver
// Even if email contains "' OR '1'='1", it is treated as a literal string
const query = 'SELECT * FROM users WHERE email = ?';
db.query(query, [req.query.email], (err, rows) => {
if (err) return res.status(500).json({ error: 'db error' });
res.json(rows);
});
});
// With an ORM like Prisma, parameterization is automatic
// const user = await prisma.user.findUnique({ where: { email: req.query.email } });
NoSQL Injection in MongoDB
// BAD -- passing req.body directly to a Mongo query
// Attacker sends { "email": "a@b.com", "password": { "$ne": null } }
// The $ne operator matches ANY non-null password -> auth bypass
app.post('/login', async (req, res) => {
const user = await User.findOne(req.body);
if (user) return res.json({ token: signToken(user) });
});
// GOOD -- coerce inputs to strings and validate shape with zod
const { z } = require('zod');
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(200),
});
app.post('/login', async (req, res) => {
const parsed = LoginSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'invalid input' });
const { email, password } = parsed.data;
const user = await User.findOne({ email }); // typed string, not object
if (!user) return res.status(401).json({ error: 'bad credentials' });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'bad credentials' });
res.json({ token: signToken(user) });
});
Command Injection
// BAD -- exec runs a shell, so semicolons and pipes are interpreted
const { exec } = require('child_process');
app.get('/ping', (req, res) => {
// Attacker sends ?host=8.8.8.8;rm -rf /
exec('ping -c 1 ' + req.query.host, (err, out) => res.send(out));
});
// GOOD -- execFile takes args as an array, no shell involved
const { execFile } = require('child_process');
app.get('/ping', (req, res) => {
// Validate first, then pass args separately
if (!/^[0-9.]+$/.test(req.query.host)) return res.status(400).end();
execFile('ping', ['-c', '1', req.query.host], (err, out) => res.send(out));
});
A07 -- Broken Authentication: Sessions, Tokens, and Brute Force
Authentication failures cover weak passwords, missing MFA, predictable session IDs, missing rate limits, and credential stuffing. The two most common Node.js failures are storing passwords in plain text (or with MD5/SHA1) and allowing unlimited login attempts.
The fix: hash passwords with bcrypt (cost factor 12+) or argon2, rotate session IDs after login, set short JWT expirations with refresh tokens, and rate-limit login endpoints. We covered the bcrypt and JWT details in lessons 8.1 and 8.2 -- here we focus on the rate-limit defense.
// Brute-force protection with express-rate-limit
const rateLimit = require('express-rate-limit');
// Strict limit on the login route -- 5 attempts per 15 minutes per IP
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 5, // 5 requests per window per IP
standardHeaders: true, // Return RateLimit-* headers
legacyHeaders: false, // Disable old X-RateLimit-* headers
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
// Skip successful logins so legitimate users aren't punished
skipSuccessfulRequests: true,
});
// Looser limit on general API routes -- 100 requests per minute per IP
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
});
app.use('/api/', apiLimiter);
app.post('/login', loginLimiter, loginHandler);
For distributed deployments behind a load balancer, store the rate-limit counters in Redis with rate-limit-redis so all instances share the same view.
A02 -- Cryptographic Failures (Sensitive Data Exposure)
This category covers data that should be encrypted but isn't: passwords stored as plain text, credit cards in cleartext logs, personally identifiable information (PII) sent over HTTP, and tokens leaked in URLs.
Defenses:
- Force HTTPS everywhere. Set
Strict-Transport-Securityvia helmet. - Never log passwords, tokens, API keys, or full credit card numbers.
- Encrypt PII at rest with
crypto(AES-256-GCM) or a managed KMS. - Set the
Secure,HttpOnly, andSameSite=Strictflags on every cookie.
// Secure cookie flags -- the bare minimum for session cookies
res.cookie('session', sessionId, {
httpOnly: true, // JavaScript cannot read it
secure: true, // Only sent over HTTPS
sameSite: 'strict', // Blocks CSRF on navigation
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
path: '/',
domain: '.example.com',
});
// Redact sensitive fields before logging with pino
const pino = require('pino');
const logger = pino({
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'password',
'creditCard',
'*.password',
'*.token',
],
censor: '[REDACTED]',
},
});
XXE -- XML External Entity Attacks
If your API parses XML (SOAP, SAML, RSS), an attacker can inject an external entity that reads local files or makes server-side requests:
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<user><name>&xxe;</name></user>
The Node.js fix is simple: use a parser that disables external entities by default (fast-xml-parser) or explicitly turn them off in libxmljs. Better yet, prefer JSON. If you must accept XML, set noent: false and never load DTDs from user input.
A01 -- Broken Access Control
The #1 OWASP category. It's the difference between authentication (who you are) and authorization (what you can do). A logged-in user calling GET /api/orders/1234 should not see another customer's order.
// BAD -- only checks that the user is logged in, not that they own the order
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order); // IDOR vulnerability
});
// GOOD -- checks both authentication AND ownership
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) return res.status(404).end();
if (order.userId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'forbidden' });
}
res.json(order);
});
Rules of thumb:
- Deny by default. Every route requires explicit authorization.
- Never trust the client to tell you who it is -- read the user ID from the verified JWT or session, not from the request body.
- Use centralized RBAC middleware so policy lives in one place.
- Test access control with a non-admin user and an attacker-style fuzzing list.
A05 -- Security Misconfiguration & Helmet Headers
Security headers tell the browser how to treat your responses. Without them, the browser uses permissive defaults. The helmet package sets a sensible bundle in one line.
// Full helmet configuration with explicit comments on each header
const helmet = require('helmet');
app.use(
helmet({
// Content-Security-Policy: whitelist where scripts/styles/images can load from
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdn.example.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
fontSrc: ["'self'"],
objectSrc: ["'none'"], // No <object>, <embed>
frameAncestors: ["'none'"], // Cannot be iframed
upgradeInsecureRequests: [], // Force HTTPS for subresources
},
},
// HSTS: force HTTPS for 1 year, including subdomains
strictTransportSecurity: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
// X-Frame-Options: block clickjacking by disallowing iframes
frameguard: { action: 'deny' },
// X-Content-Type-Options: prevent MIME sniffing
noSniff: true,
// Referrer-Policy: do not leak the full URL on outbound links
referrerPolicy: { policy: 'no-referrer' },
// Permissions-Policy: disable browser features the app does not need
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
// Hide the X-Powered-By: Express header from attackers
hidePoweredBy: true,
// Prevent IE from executing downloads in your site's context
ieNoOpen: true,
// DNS prefetch control: opt out for privacy
dnsPrefetchControl: { allow: false },
// Cross-Origin-Opener-Policy: isolate browsing contexts
crossOriginOpenerPolicy: { policy: 'same-origin' },
// Cross-Origin-Resource-Policy: only same-origin can fetch your responses
crossOriginResourcePolicy: { policy: 'same-origin' },
})
);
Other misconfigurations to fix:
NODE_ENV=productionin production. Express skips verbose error pages and stack traces.- Disable directory listing on static file middleware.
- Remove default credentials and example routes (
/admin/test). - Turn off CORS wildcards. Whitelist exact origins.
A03b -- Cross-Site Scripting (XSS)
XSS happens when user input is rendered as HTML without escaping. In Node.js APIs, the most common path is server-rendered templates (EJS, Pug) with raw output.
Defenses:
- Always escape output. EJS uses
<%= value %>(escaped) instead of<%- value %>(raw). - Sanitize rich HTML input with
DOMPurify(server-side viajsdom) before storing it. - Set a strict
Content-Security-Policy(helmet does this). - Set
HttpOnlyon auth cookies so a successful XSS cannot steal them.
A08 -- Insecure Deserialization
Never call eval(), Function(), or vm.runInNewContext() on untrusted input. Avoid node-serialize and any package that supports "function deserialization." Stick to JSON, validate the parsed shape with zod, and reject unknown fields.
// BAD -- node-serialize will execute attacker-supplied IIFEs
const serialize = require('node-serialize');
const obj = serialize.unserialize(req.body.payload); // RCE if attacker controls payload
// GOOD -- JSON.parse + schema validation
const Schema = z.object({ id: z.string(), qty: z.number().int().positive() });
const data = Schema.parse(JSON.parse(req.body.payload));
A06 -- Vulnerable and Outdated Components
Your node_modules folder has hundreds of transitive dependencies. Any one of them can ship a critical CVE. The fix is automation: scan, alert, patch.
# Built-in: npm audit reports known vulnerabilities in your dependency tree
npm audit
# Show only high and critical issues, fail CI if any exist
npm audit --audit-level=high
# Try to auto-fix by upgrading to safe semver-compatible versions
npm audit fix
# Force upgrades that cross major versions (review breaking changes!)
npm audit fix --force
# Snyk: deeper scanning with remediation advice
npx snyk test
npx snyk monitor
# License + supply-chain checks with socket.dev
npx socket-cli scan
Add a CI step that runs npm audit --audit-level=high on every pull request, and enable Dependabot (GitHub) or Renovate to open automatic upgrade PRs. Pin your Node.js version with .nvmrc and the engines field in package.json.
Environment Variable Security & Validation
Secrets do not belong in source code, in the Docker image, or in client-side bundles. They live in environment variables, loaded from a secret manager (AWS Secrets Manager, Vault, Doppler) or a .env file that is never committed.
// env.js -- validate environment variables at boot time, fail fast if missing
const { z } = require('zod');
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
REDIS_URL: z.string().url(),
STRIPE_SECRET: z.string().startsWith('sk_'),
CORS_ORIGIN: z.string().url(),
});
// Throws a descriptive error and exits if any required var is missing or invalid
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
// Export a typed, frozen config object
module.exports = Object.freeze(parsed.data);
# .gitignore -- always include these
.env
.env.local
.env.*.local
*.pem
*.key
secrets/
CSRF Protection for Cookie Sessions
If your app uses cookie-based sessions (not bearer tokens), you must defend against Cross-Site Request Forgery. An attacker's site triggers a state-changing request to your API while the user's session cookie rides along automatically.
// CSRF protection with the csurf middleware (or modern alternative csrf-csrf)
const csrf = require('csurf');
// Issue a CSRF token tied to the session, stored in a separate cookie
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
},
});
// Apply to state-changing routes only (POST, PUT, DELETE)
app.post('/api/transfer', csrfProtection, (req, res) => {
// The client must include the token in a header or form field
// The middleware validates it before this handler runs
doTransfer(req.body);
res.json({ ok: true });
});
// Send the token to the client so it can echo it back
app.get('/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
For pure JSON APIs with Authorization: Bearer tokens (no cookies), CSRF is not exploitable because attackers cannot read or set custom headers cross-origin.
A09 -- Insufficient Logging and Monitoring
If you cannot detect a breach, you cannot stop it. The average breach goes undetected for 194 days. Audit trails turn that into minutes.
Log every:
- Login success and failure (with IP, user agent, user ID)
- Password change, MFA change, email change
- Permission change, role grant, admin action
- Failed authorization (403 responses)
- Rate-limit triggers
- 5xx errors and unhandled exceptions
Do not log:
- Passwords (even hashed -- redact)
- Full JWT or session tokens
- Credit card numbers or CVVs
- Personal data subject to GDPR without a lawful basis
Ship logs to a central system (Datadog, Splunk, Loki, ELK) with retention and alerting. Set up alerts for anomalies: 100 failed logins from one IP in a minute, a sudden spike in 403s, or a deploy of an unsigned artifact.
A10 -- Server-Side Request Forgery (SSRF)
SSRF happens when your server fetches a URL supplied by the user. An attacker points it at http://169.254.169.254/latest/meta-data/ (AWS metadata) and steals IAM credentials.
Defenses:
- Whitelist allowed hostnames before calling
fetch. - Block private IP ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,169.254.0.0/16). - Disable redirects, or follow them only to whitelisted hosts.
- Use IMDSv2 on AWS so the metadata endpoint requires a session token.
Principle of Least Privilege
Every component should have the minimum permissions it needs and no more.
- Database user: the app's DB user can
SELECT,INSERT,UPDATE,DELETEon its own tables -- nothing else. NoDROP, no access tomysql.user, no other databases. - File system: run Node.js as a non-root user. Mount config as read-only. Use a writable scratch directory only if needed.
- Cloud IAM: the EC2/Lambda role can read its own S3 bucket, write its own queue, and nothing else. No
*:*. - Containers: drop Linux capabilities, run as a non-root UID, use a read-only root filesystem.
- Inside the app: an admin route requires an admin role check, even if the route is "hidden."
When (not if) something is compromised, least privilege is what limits the blast radius.
Common Mistakes
1. Storing secrets in source control or Docker images.
A .env file committed to GitHub is a credential leak waiting for a bot to find it. Use a secrets manager and add .env to .gitignore. Rotate any secret that touched git history -- removing the file is not enough.
2. Trusting req.body.userId for authorization.
The user ID for authorization checks must come from a verified JWT or server-side session, never from the request body, query string, or a header the client controls. Otherwise an attacker rewrites the field and impersonates anyone.
3. Disabling helmet because "the CSP breaks our site."
A broken site is a fixable problem. A wide-open app is not. Tune the CSP directives -- whitelist your CDN, your analytics domain, your image host -- but never ship without security headers.
4. Using Math.random() for tokens, IDs, or password reset codes.
Math.random() is not cryptographically secure. Use crypto.randomBytes(32).toString('hex') for any value an attacker would benefit from guessing.
5. Skipping npm audit in CI.
A vulnerability scan that runs only on a developer's laptop is not a control. Add npm audit --audit-level=high to your CI pipeline and fail the build on critical findings. Pair it with Dependabot for automatic upgrade PRs.
Interview Questions
1. "Walk me through the OWASP Top 10. Which categories matter most for a Node.js API?"
The OWASP Top 10 ranks the most critical web application risks: A01 Broken Access Control, A02 Cryptographic Failures, A03 Injection, A04 Insecure Design, A05 Security Misconfiguration, A06 Vulnerable Components, A07 Identification and Authentication Failures, A08 Software and Data Integrity Failures, A09 Logging and Monitoring Failures, and A10 Server-Side Request Forgery. For a Node.js API, the highest-impact categories are A01 (broken access control -- IDOR is everywhere), A03 (injection, especially NoSQL injection on Mongo with $ne/$gt operators), A06 (vulnerable npm dependencies, since node_modules is huge), and A07 (broken auth -- missing rate limits on login, weak JWT secrets, sessions that don't rotate). I defend each one with a specific control: parameterized queries and zod validation for injection, helmet plus deny-by-default RBAC for access control, npm audit and Dependabot for dependencies, and bcrypt plus express-rate-limit for auth.
2. "How do you prevent SQL and NoSQL injection in a Node.js app?"
For SQL, I never concatenate user input into a query string. I use parameterized queries with ? or $1 placeholders -- the database driver sends the query and the values separately, so the engine never confuses data with code. Even if the input contains SQL meta-characters, they are treated as literals. With an ORM like Prisma or TypeORM, parameterization is automatic. For NoSQL on MongoDB, the danger is operator injection: an attacker sends {"$ne": null} as a password, and User.findOne({ password: req.body.password }) matches every user. The fix is to validate input shape with zod before it reaches the query, coercing fields to primitives so an object literal can never sneak in. I also use the principle of least privilege on the database user so the app cannot run DROP TABLE even if injection succeeds.
3. "Why do we need helmet, and which header is most important?"
helmet sets a bundle of HTTP security headers that browsers use to enforce client-side defenses. Without them, you rely on browser defaults which are deliberately permissive for backward compatibility. The single most important header is Content-Security-Policy. CSP whitelists the exact origins your page can load scripts, styles, and connections from -- which means an attacker who finds an XSS hole still cannot exfiltrate data to their domain or load malicious scripts from elsewhere. After CSP, the next-most-critical headers are Strict-Transport-Security (forces HTTPS for a year), X-Frame-Options: deny (blocks clickjacking by preventing your site from being iframed), and X-Content-Type-Options: nosniff (stops the browser from guessing MIME types and executing a .txt file as JavaScript).
4. "How do you defend a login endpoint from brute force and credential stuffing?"
Layered defense. First, hash passwords with bcrypt at cost factor 12 or argon2id, so each guess is computationally expensive even for the attacker. Second, apply express-rate-limit to the login route -- I typically set 5 attempts per 15 minutes per IP, with skipSuccessfulRequests: true so legitimate users aren't punished. Third, in distributed deployments, store the rate-limit counters in Redis with rate-limit-redis so all instances share state. Fourth, add an account-level lockout after, say, 10 failed attempts within an hour, with an unlock email. Fifth, require MFA for high-value accounts. Sixth, monitor login failures by IP and user, and alert on credential-stuffing patterns -- for example, 1000 unique usernames with one password from one ASN. Finally, integrate with a breached-password API like haveibeenpwned to refuse known-compromised passwords at signup.
5. "How do you handle environment variables and secrets in production Node.js?"
Three rules. First, secrets never enter source control. The .env file is gitignored, and any secret that ever touched git history is rotated immediately. Second, secrets are loaded from a managed secrets store -- AWS Secrets Manager, HashiCorp Vault, Doppler, or at minimum environment variables injected by the orchestrator (Kubernetes Secrets, ECS task definitions). Third, environment variables are validated at boot time with a zod schema. I parse process.env against a schema that requires DATABASE_URL to be a URL, JWT_SECRET to be at least 32 characters, and so on. If parsing fails, the process exits before serving any traffic, which guarantees we never run with a misconfigured environment. The validated config is exported as a frozen object so the rest of the app uses typed, immutable values instead of reaching into process.env directly.
Quick Reference -- Security Cheat Sheet
+---------------------------------------------------------------+
| NODE.JS SECURITY CHEAT SHEET |
+---------------------------------------------------------------+
| |
| INJECTION: |
| - Parameterized queries (?, $1) -- never concat strings |
| - zod-validate input shape before passing to Mongo |
| - execFile (not exec) for child_process |
| |
| AUTH: |
| - bcrypt cost 12+ / argon2id |
| - express-rate-limit on /login (5 / 15min) |
| - JWT short expiry + refresh token rotation |
| - Rotate session ID after login |
| |
| HEADERS (helmet): |
| - Content-Security-Policy (strict whitelist) |
| - Strict-Transport-Security (1 year, preload) |
| - X-Frame-Options: deny |
| - X-Content-Type-Options: nosniff |
| - Referrer-Policy: no-referrer |
| |
| COOKIES: |
| - httpOnly: true |
| - secure: true |
| - sameSite: 'strict' |
| |
| DEPENDENCIES: |
| - npm audit --audit-level=high in CI |
| - Dependabot / Renovate for auto-PRs |
| - Snyk or socket.dev for deeper scanning |
| - Pin Node version in .nvmrc and engines |
| |
| SECRETS: |
| - Never commit .env |
| - Validate process.env with zod at boot |
| - Use a secrets manager in production |
| - Rotate any leaked secret immediately |
| |
| LEAST PRIVILEGE: |
| - DB user: only the tables it needs |
| - Run Node as non-root, read-only filesystem |
| - Cloud IAM: scoped to specific resources |
| |
| LOGGING: |
| - Log auth events, 403s, rate-limit hits |
| - Redact passwords, tokens, PII |
| - Ship to central system with alerts |
| |
+---------------------------------------------------------------+
| OWASP Category | Node.js Defense |
|---|---|
| A01 Broken Access Control | Deny-by-default RBAC, ownership checks, ID from JWT |
| A02 Cryptographic Failures | HTTPS, HSTS, bcrypt, AES-256-GCM, secure cookies |
| A03 Injection | Parameterized queries, zod validation, execFile |
| A04 Insecure Design | Threat modeling, secure-by-default architecture |
| A05 Security Misconfiguration | helmet, NODE_ENV=production, no defaults |
| A06 Vulnerable Components | npm audit, Snyk, Dependabot, pinned versions |
| A07 Auth Failures | bcrypt, rate-limit, MFA, session rotation |
| A08 Integrity Failures | No eval, JSON only, signed artifacts, SRI |
| A09 Logging Failures | Audit trail, redaction, central logs, alerts |
| A10 SSRF | Hostname whitelist, block private IPs, IMDSv2 |
Prev: Lesson 8.3 -- OAuth & Social Login Next: Lesson 9.1 -- Unit Testing with Jest
This is Lesson 8.4 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.