Skip to content

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 HttpOnly cookies
  • Keep dependencies patched — run npm audit in CI on every PR
  • Define an explicit CORS allowlistorigin: '*' 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