
Most Node.js tutorials focus on building features fast. They skip the security layer entirely, leaving applications exposed to injection attacks, broken authentication, and data leaks. In production, a single missed vulnerability can compromise your entire system - and your users' trust.
This guide covers the security practices we implement at Dude Lemon for every client application we ship to production. These are not theoretical recommendations - they are the exact patterns running in our deployed systems right now.
1) HTTP Security Headers With Helmet.js
The fastest security win for any Express application is adding proper HTTP headers. The helmet middleware sets headers that prevent clickjacking, XSS attacks, MIME-type sniffing, and other common browser-level exploits. Every production application should include this as baseline infrastructure.
1import helmet from 'helmet'2import cors from 'cors'3 4export function applySecurityMiddleware(app) {5 // Core HTTP security headers6 app.use(helmet({7 contentSecurityPolicy: {8 directives: {9 defaultSrc: ["'self'"],10 scriptSrc: ["'self'"],11 styleSrc: ["'self'", "'unsafe-inline'"],12 imgSrc: ["'self'", "data:", "https:"],13 connectSrc: ["'self'"],14 fontSrc: ["'self'"],15 objectSrc: ["'none'"],16 mediaSrc: ["'self'"],17 frameSrc: ["'none'"],18 },19 },20 crossOriginEmbedderPolicy: true,21 crossOriginOpenerPolicy: true,22 crossOriginResourcePolicy: { policy: "same-site" },23 dnsPrefetchControl: true,24 hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },25 referrerPolicy: { policy: "strict-origin-when-cross-origin" },26 }))27 28 // CORS - restrict to known origins29 app.use(cors({30 origin: process.env.ALLOWED_ORIGINS?.split(',') || [],31 credentials: true,32 methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],33 allowedHeaders: ['Content-Type', 'Authorization'],34 }))35 36 // Remove server fingerprint37 app.disable('x-powered-by')38}The Content Security Policy (CSP) header is the most impactful. It tells the browser exactly which sources are allowed to load scripts, styles, images, and other resources. A strict CSP blocks the majority of XSS attacks because injected scripts cannot execute when they violate the policy.
2) Input Validation and Sanitization
Never trust user input. Every request body, query parameter, and URL segment should be validated against an explicit schema before it reaches your business logic. We use Joi for validation in our REST API projects - it provides type checking, string constraints, and custom error messages in a declarative syntax.
1import Joi from 'joi'2import sanitizeHtml from 'sanitize-html'3 4// Reusable validation middleware5export function validate(schema, source = 'body') {6 return (req, res, next) => {7 const { error, value } = schema.validate(req[source], {8 abortEarly: false,9 stripUnknown: true,10 })11 12 if (error) {13 const messages = error.details.map(d => d.message)14 return res.status(400).json({15 error: 'Validation failed',16 details: messages,17 })18 }19 20 req[source] = value21 next()22 }23}24 25// Sanitize HTML in string fields to prevent stored XSS26export function sanitizeInput(obj) {27 const clean = {}28 for (const [key, val] of Object.entries(obj)) {29 if (typeof val === 'string') {30 clean[key] = sanitizeHtml(val, {31 allowedTags: [],32 allowedAttributes: {},33 })34 } else {35 clean[key] = val36 }37 }38 return clean39}40 41// Example: user registration schema42export const registerSchema = Joi.object({43 email: Joi.string().email().required().max(255),44 password: Joi.string().min(12).max(128).required()45 .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])/)46 .message('Password must contain uppercase, lowercase, number, and special character'),47 full_name: Joi.string().min(2).max(100).required()48 .pattern(/^[a-zA-Z\s'-]+$/)49 .message('Name contains invalid characters'),50})The stripUnknown: true option is critical - it removes any fields not defined in the schema, which prevents mass-assignment attacks where an attacker sends extra fields like isAdmin: true in the request body.

3) Authentication: JWT Best Practices
JSON Web Tokens are the standard for stateless authentication in Node.js APIs. But most implementations get the details wrong - storing tokens in localStorage (XSS vulnerable), using long expiration times, or skipping token rotation entirely. Here is the pattern we use for production applications.
1import jwt from 'jsonwebtoken'2 3const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET4const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET5 6export function generateTokens(user) {7 const accessToken = jwt.sign(8 { sub: user.id, role: user.role },9 ACCESS_SECRET,10 { expiresIn: '15m', algorithm: 'HS256' }11 )12 13 const refreshToken = jwt.sign(14 { sub: user.id, tokenVersion: user.token_version },15 REFRESH_SECRET,16 { expiresIn: '7d', algorithm: 'HS256' }17 )18 19 return { accessToken, refreshToken }20}21 22export function verifyAccess(token) {23 return jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] })24}25 26export function verifyRefresh(token) {27 return jwt.verify(token, REFRESH_SECRET, { algorithms: ['HS256'] })28}29 30// Middleware: extract token from httpOnly cookie, not Authorization header31export function authenticate(req, res, next) {32 const token = req.cookies?.accessToken33 if (!token) return res.status(401).json({ error: 'Authentication required' })34 35 try {36 req.user = verifyAccess(token)37 next()38 } catch (err) {39 if (err.name === 'TokenExpiredError') {40 return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' })41 }42 return res.status(401).json({ error: 'Invalid token' })43 }44}- Store tokens in httpOnly cookies - never in localStorage or sessionStorage
- Use short-lived access tokens (15 minutes) with longer refresh tokens (7 days)
- Include a tokenVersion field so you can invalidate all sessions for a user
- Always specify the algorithm explicitly to prevent algorithm confusion attacks
- Set Secure and SameSite=Strict flags on authentication cookies
For applications requiring stronger authentication, consider implementing WebAuthn passkeys - they eliminate password-based attacks entirely and provide phishing-resistant authentication.
4) Rate Limiting and Brute Force Protection
Rate limiting prevents abuse by restricting how many requests a client can make within a time window. Without it, your API is vulnerable to brute force login attempts, credential stuffing, and denial-of-service attacks. We implement rate limiting at both the application level and at the Nginx reverse proxy level.
1import rateLimit from 'express-rate-limit'2import RedisStore from 'rate-limit-redis'3import { createClient } from 'redis'4 5const redis = createClient({ url: process.env.REDIS_URL })6redis.connect()7 8// General API rate limit9export const apiLimiter = rateLimit({10 store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),11 windowMs: 15 * 60 * 1000, // 15 minutes12 max: 100,13 standardHeaders: true,14 legacyHeaders: false,15 keyGenerator: (req) => req.ip,16 handler: (req, res) => {17 res.status(429).json({18 error: 'Too many requests',19 retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),20 })21 },22})23 24// Strict limiter for auth endpoints25export const authLimiter = rateLimit({26 store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),27 windowMs: 15 * 60 * 1000,28 max: 5,29 keyGenerator: (req) => `auth:${req.ip}:${req.body?.email || 'unknown'}`,30 skipSuccessfulRequests: true,31})32 33// Usage in routes34// app.use('/api', apiLimiter)35// app.use('/api/auth/login', authLimiter)Using Redis as the rate limit store is essential for production. In-memory stores reset when the server restarts and do not work in PM2 cluster mode where multiple processes handle requests. Redis provides a shared, persistent counter across all processes.
5) Dependency Auditing and Supply Chain Security
Your application is only as secure as its dependencies. A single compromised npm package can inject malicious code into your production build. Automated dependency auditing should be part of every CI/CD pipeline.
1# Run npm audit as part of CI2npm audit --audit-level=high3 4# Use Snyk for deeper vulnerability scanning5npx snyk test --severity-threshold=high6 7# Lock dependencies - always commit package-lock.json8# Use exact versions for critical packages9npm install --save-exact express@4.21.010 11# Check for known malicious packages12npx socket-security/cli scan- Run
npm auditin CI and fail the build on high-severity vulnerabilities - Use
package-lock.json- never delete it, always commit it - Pin critical dependencies to exact versions
- Review changelogs before upgrading major versions
- Use
npm lsto understand your full dependency tree - Consider using Socket.dev or Snyk for continuous monitoring
6) Secrets Management
Hardcoded secrets are the number one cause of production security incidents. API keys, database credentials, and encryption keys must never appear in source code. Use environment variables with a structured approach.
1// Validate required environment variables at startup2const required = [3 'DATABASE_URL',4 'JWT_ACCESS_SECRET',5 'JWT_REFRESH_SECRET',6 'REDIS_URL',7 'ALLOWED_ORIGINS',8]9 10const missing = required.filter(key => !process.env[key])11if (missing.length > 0) {12 console.error('Missing required environment variables:', missing.join(', '))13 process.exit(1)14}15 16export const config = {17 port: parseInt(process.env.PORT, 10) || 3000,18 databaseUrl: process.env.DATABASE_URL,19 jwtAccessSecret: process.env.JWT_ACCESS_SECRET,20 jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,21 redisUrl: process.env.REDIS_URL,22 allowedOrigins: process.env.ALLOWED_ORIGINS.split(','),23 nodeEnv: process.env.NODE_ENV || 'development',24}- Validate all required env vars at startup - fail fast, not at runtime
- Never log environment variables or include them in error responses
- Use AWS Secrets Manager or Parameter Store for production credentials
- Rotate secrets on a regular schedule and after any team change
- Add
.envto.gitignoreand use.env.examplefor documentation

7) SQL Injection Prevention
SQL injection remains one of the most dangerous web vulnerabilities. If you are building APIs with PostgreSQL, always use parameterized queries - never concatenate user input into SQL strings.
1import pool from './pool.js'2 3// WRONG - vulnerable to SQL injection4// const result = await pool.query(`SELECT * FROM users WHERE email = '${email}'`)5 6// CORRECT - parameterized query7export async function findUserByEmail(email) {8 const { rows } = await pool.query(9 'SELECT id, email, full_name, role FROM users WHERE email = $1',10 [email]11 )12 return rows[0] || null13}14 15// CORRECT - parameterized query with multiple parameters16export async function searchProjects(userId, status, limit = 20) {17 const { rows } = await pool.query(18 `SELECT id, name, status, created_at19 FROM projects20 WHERE user_id = $1 AND ($2::text IS NULL OR status = $2)21 ORDER BY created_at DESC22 LIMIT $3`,23 [userId, status || null, limit]24 )25 return rows26}Parameterized queries send the SQL structure and user data separately, making it impossible for input to alter the query logic. This is non-negotiable for any production application.
8) Logging, Monitoring, and Incident Response
Security is not just about prevention - you also need visibility into what is happening in your application. Structured logging, error tracking, and audit trails help you detect and respond to incidents before they escalate.
1import { randomUUID } from 'crypto'2 3export function requestLogger(req, res, next) {4 const requestId = randomUUID()5 req.requestId = requestId6 res.setHeader('X-Request-Id', requestId)7 8 const start = Date.now()9 10 res.on('finish', () => {11 const duration = Date.now() - start12 const log = {13 requestId,14 method: req.method,15 path: req.originalUrl,16 status: res.statusCode,17 duration,18 ip: req.ip,19 userAgent: req.get('User-Agent'),20 userId: req.user?.sub || null,21 }22 23 // Flag suspicious activity24 if (res.statusCode === 401 || res.statusCode === 403) {25 log.level = 'warn'26 log.event = 'auth_failure'27 } else if (res.statusCode >= 500) {28 log.level = 'error'29 } else {30 log.level = 'info'31 }32 33 console.log(JSON.stringify(log))34 })35 36 next()37}- Use structured JSON logging - it is parseable by CloudWatch, Datadog, and ELK
- Attach a unique request ID to every request for traceability
- Log authentication failures and rate limit hits at warn level
- Never log sensitive data: passwords, tokens, credit card numbers, or PII
- Set up alerts for unusual patterns: spike in 401s, sudden traffic increase, or new IPs hitting admin routes
9) Production Security Checklist
Use this checklist before every production deployment. These are the minimum security requirements for any Node.js application handling real user data.
- Helmet.js with strict CSP headers configured
- CORS restricted to known origins only
- All user input validated with Joi or Zod schemas
- HTML sanitization on all string inputs
- JWT tokens stored in httpOnly, Secure, SameSite cookies
- Short-lived access tokens with refresh token rotation
- Rate limiting on all endpoints, strict limits on auth routes
- Parameterized queries for all database operations
- npm audit passing with no high-severity vulnerabilities
- Environment variables validated at startup
- Secrets stored in AWS Secrets Manager or equivalent
.envfiles excluded from version control- Structured logging with request IDs
- HTTPS enforced via Nginx and HSTS headers
- Error responses that never leak stack traces or internal details
Conclusion: Security Is Continuous
Securing a Node.js application is not a one-time task. New vulnerabilities are discovered in npm packages every week, attack techniques evolve, and your application's attack surface grows with every feature you ship. The practices in this guide give you a strong foundation, but production security requires ongoing auditing, dependency updates, and threat modeling.
At Dude Lemon, we build security into every layer of our client applications - from input validation and authentication to infrastructure hardening and compliance frameworks like NIST 800-53 and SOC 2. If you need help securing your Node.js application or conducting a security audit of your existing codebase, reach out to our security engineering team.
Need help building this?
Let our team build it for you.
Dude Lemon builds production-grade web apps, APIs, and cloud infrastructure. Get a free consultation and project proposal within 48 hours.
Start a project