Node.js – Security
Security is not a feature to add at the end — it is built into every layer from the start. This page defines the mandatory security controls for all Cygnus Dynamics Node.js services.
Core Rules
- Never log passwords, tokens, card numbers, or PII — in any log level
- Store all secrets in environment variables — never in code or version control
- Sanitise all user inputs before using them in queries, commands, or templates
- Set security headers with Helmet on every Express application
- Rate-limit all public endpoints — authenticated and unauthenticated
- Use parameterised queries — never concatenate user input into SQL
- Validate and sign JWTs correctly — store tokens in
HttpOnlycookies - Keep dependencies patched — run
npm auditin CI on every PR - Define an explicit CORS allowlist —
origin: '*'is never acceptable in production
Security Headers — Helmet
helmet sets 14 HTTP security headers in one call. It must be the first middleware registered on the Express app.
import helmet from 'helmet'
// ✅ Correct — helmet applied first, before routes and body parsers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // adjust for your CSP needs
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.cygnusdynamics.com'],
},
},
crossOriginEmbedderPolicy: true,
hsts: {
maxAge: 31_536_000, // 1 year in seconds
includeSubDomains: true,
preload: true,
},
}))
// Headers set by helmet — verify with securityheaders.com
// X-DNS-Prefetch-Control, X-Frame-Options, X-Content-Type-Options,
// Referrer-Policy, X-XSS-Protection, Content-Security-Policy,
// Strict-Transport-Security, Cross-Origin-Opener-Policy, ...
CORS Policy
Define an explicit allowlist of permitted origins. Never use origin: '*' in production — it disables the browser's same-origin protection for your API.
import cors from 'cors'
import { config } from '../config'
// ✅ Correct — explicit allowlist from environment config
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
// Allow requests with no origin (e.g. mobile apps, Postman in dev)
if (!origin) return callback(null, true)
if (config.allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error(`CORS: origin ${origin} not permitted`))
}
},
credentials: true, // required for cookie-based auth
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86_400, // preflight cache: 24 hours
}
app.use(cors(corsOptions))
app.options('*', cors(corsOptions)) // handle preflight for all routes
// config/.env.example
// ALLOWED_ORIGINS=https://app.cygnusdynamics.com,https://admin.cygnusdynamics.com
// ❌ Incorrect — allows any origin
app.use(cors())
app.use(cors({ origin: '*' }))
app.use(cors({ origin: true })) // reflects the request origin — same as *
Authentication — JWT
Tokens are signed with a strong secret, validated on every protected request, stored in HttpOnly cookies (not localStorage), and short-lived.
Issuing tokens
import jwt from 'jsonwebtoken'
import { config } from '../config'
interface TokenPayload {
sub: string // user ID — subject
email: string
role: string
iat?: number // issued at (added by jwt.sign)
exp?: number // expiry (added by jwt.sign)
}
// Access token — short-lived
export const signAccessToken = (payload: Omit<TokenPayload, 'iat' | 'exp'>): string =>
jwt.sign(payload, config.jwtSecret, {
expiresIn: config.jwtExpiresIn, // e.g. '15m'
issuer: 'api.cygnusdynamics.com',
audience: 'cygnusdynamics.com',
})
// Refresh token — longer-lived, stored separately
export const signRefreshToken = (userId: string): string =>
jwt.sign({ sub: userId }, config.jwtRefreshSecret, {
expiresIn: '7d',
issuer: 'api.cygnusdynamics.com',
})
// ✅ Send access token in HttpOnly cookie — NOT in response body
export const login = asyncHandler(async (req: Request, res: Response) => {
const { email, password } = req.body as LoginRequest
const user = await authService.validateCredentials(email, password)
const accessToken = signAccessToken({ sub: user.id, email: user.email, role: user.role })
const refreshToken = signRefreshToken(user.id)
// Store refresh token in DB for rotation/revocation
await authService.storeRefreshToken(user.id, refreshToken)
res
.cookie('access_token', accessToken, {
httpOnly: true, // not accessible via document.cookie
secure: config.nodeEnv === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes in ms
})
.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: config.nodeEnv === 'production',
sameSite: 'strict',
path: '/api/v1/auth/refresh', // restrict to the refresh endpoint
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
})
.json({ data: { id: user.id, email: user.email, role: user.role } })
})
Validating tokens
// middleware/auth.middleware.ts
import jwt from 'jsonwebtoken'
import { config } from '../config'
import { UnauthorisedError } from '../utils/errors'
import type { Request, Response, NextFunction } from 'express'
// Augment Express request type — src/types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: { id: string; email: string; role: string }
}
}
}
export const authenticate = (req: Request, _res: Response, next: NextFunction): void => {
const token = req.cookies?.access_token
?? req.headers.authorization?.replace('Bearer ', '') // fallback for API clients
if (!token) throw new UnauthorisedError('No token provided')
try {
const payload = jwt.verify(token, config.jwtSecret, {
issuer: 'api.cygnusdynamics.com',
audience: 'cygnusdynamics.com',
}) as { sub: string; email: string; role: string }
req.user = { id: payload.sub, email: payload.email, role: payload.role }
next()
} catch {
throw new UnauthorisedError('Invalid or expired token')
}
}
// Role-based access control middleware
export const requireRole = (...roles: string[]) =>
(req: Request, _res: Response, next: NextFunction): void => {
if (!req.user || !roles.includes(req.user.role)) {
throw new ForbiddenError()
}
next()
}
// Usage in routes
router.get('/admin/users', authenticate, requireRole('admin'), asyncHandler(usersController.listAll))
Rate Limiting
Rate-limit all public endpoints. Set stricter limits on sensitive endpoints (login, password reset, OTP).
import rateLimit from 'express-rate-limit'
// ✅ General API rate limit — applied to all routes
export const createRateLimiter = () => rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 100, // 100 requests per window per IP
standardHeaders: 'draft-7', // adds RateLimit headers per RFC 9110
legacyHeaders: false,
message: { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests, please try again later.' } },
})
// ✅ Strict limit for auth endpoints
export const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes per IP
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: true, // only count failures
message: { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many login attempts. Please try again in 15 minutes.' } },
})
// Apply in routes
app.use('/api/v1', createRateLimiter())
app.use('/api/v1/auth/login', authRateLimiter)
app.use('/api/v1/auth/forgot-password', authRateLimiter)
Parameterised Queries
Never build SQL strings by concatenating user input. This is the single most critical database security rule — SQL injection remains one of the most common and destructive attack vectors.
// ✅ Correct — parameterised queries with pg (node-postgres)
import { pool } from '../config/database'
const findUserByEmail = async (email: string) => {
const result = await pool.query(
'SELECT id, email, role FROM users WHERE email = $1 AND deleted_at IS NULL',
[email] // ← parameter — never concatenated
)
return result.rows[0] ?? null
}
// ✅ Correct — Prisma ORM (parameterised by default)
const findUserByEmail = async (email: string) =>
prisma.user.findFirst({
where: { email, deletedAt: null },
select: { id: true, email: true, role: true },
})
// ✅ Correct — raw query in Prisma when needed
const searchOrders = async (userId: string, term: string) =>
prisma.$queryRaw`
SELECT id, reference, total
FROM orders
WHERE user_id = ${userId}
AND reference ILIKE ${'%' + term + '%'}
ORDER BY created_at DESC
LIMIT 50
`
// ❌ Catastrophically wrong — SQL injection vulnerability
const findUserByEmail = async (email: string) => {
const result = await pool.query(
`SELECT * FROM users WHERE email = '${email}'` // DROP TABLE users; --
)
return result.rows[0]
}
Environment Variables and Secrets
// ✅ Correct — secrets from environment, validated at startup
const jwtSecret = config.jwtSecret // validated by Zod schema at startup
// ✅ Correct — never log the secret value
logger.info({ hasJwtSecret: !!config.jwtSecret }, 'Config loaded')
// ❌ Never hardcode secrets — even in non-production code
const jwtSecret = 'supersecret123' // hardcoded — commit risk
const apiKey = 'sk_live_abcdefg' // hardcoded
// ❌ Never log secrets
logger.debug({ jwtSecret: config.jwtSecret }, 'Config') // secret in logs
logger.info({ password: req.body.password }, 'Login attempt')
// ❌ Never commit .env files
// .gitignore must contain:
// .env
// .env.local
// .env.*.local
For production, use a dedicated secrets manager:
| Platform | Tool |
|---|---|
| AWS | AWS Secrets Manager / Parameter Store |
| GCP | Secret Manager |
| Azure | Key Vault |
| Platform-agnostic | HashiCorp Vault, Doppler |
Dependency Vulnerability Scanning
Run npm audit on every pull request. High and critical severity findings must be fixed before merge.
# In CI — fails if any high or critical vulnerabilities are found
npm audit --audit-level=high
# For a detailed report
npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "high" or .value.severity == "critical")'
Add to your CI pipeline:
# .github/workflows/ci.yml
- name: Audit dependencies
run: npm audit --audit-level=high
- name: Check for outdated packages
run: npx npm-check-updates --errorLevel 2
Enable Dependabot or Renovate on every repository to receive automated PRs for security patches:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
groups:
production-dependencies:
dependency-type: production
Input Sanitisation
Validate and sanitise all inputs from external sources before using them. Validation (Zod) ensures correct types — sanitisation removes dangerous characters.
import DOMPurify from 'isomorphic-dompurify'
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
const window = new JSDOM('').window
const purify = createDOMPurify(window as unknown as Window)
// ✅ Sanitise HTML input before storing or rendering
export const sanitiseHtml = (input: string): string =>
purify.sanitize(input, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'] })
// ✅ Escape for display contexts — use a templating engine that auto-escapes
// (Handlebars, EJS with <%=, Pug — all escape by default)
// Never use <%- in EJS or {{{ in Handlebars}} with user content
// ✅ Path traversal prevention — never trust file path inputs
import path from 'path'
const safePath = path.resolve('/uploads', path.basename(req.params.filename))
// path.basename strips ../ components
Security Checklist
Use this checklist on every PR that touches authentication, authorization, or data access:
Authentication & Authorisation
- [ ] All protected endpoints use the authenticate middleware
- [ ] Role-specific endpoints use requireRole
- [ ] JWTs are validated (signature, expiry, issuer, audience)
- [ ] Tokens are stored in HttpOnly cookies, not localStorage
- [ ] Refresh token rotation is implemented
Input & Output - [ ] All inputs validated with Zod at the route level - [ ] All SQL queries use parameterised statements - [ ] User-supplied HTML is sanitised before storage or rendering - [ ] Error responses never include stack traces or internal details
Infrastructure
- [ ] helmet is the first middleware on the app
- [ ] CORS allowlist is explicit — no origin: '*'
- [ ] Rate limiting is applied to all endpoints
- [ ] No secrets in code, config files, or commit history
- [ ] npm audit --audit-level=high passes
OWASP Top 10 Coverage
| OWASP Risk | Coverage in these standards |
|---|---|
| A01 Broken Access Control | authenticate, requireRole, ownership checks |
| A02 Cryptographic Failures | HTTPS only, JWTs signed HS256+, HttpOnly cookies |
| A03 Injection | Parameterised queries, Zod validation, DOMPurify |
| A04 Insecure Design | Layer separation, explicit error hierarchy |
| A05 Security Misconfiguration | helmet, explicit CORS, validated env config |
| A06 Vulnerable Components | npm audit in CI, Dependabot |
| A07 Auth Failures | Short-lived JWTs, refresh rotation, strict rate limits |
| A08 Software & Data Integrity | Lockfile committed, npm audit |
| A09 Logging & Monitoring Failures | Pino structured logging, no sensitive data in logs |
| A10 Server-Side Request Forgery | Timeout + allowlist on all outbound requests |