Security¶
Express apps die from a thousand cuts. Missing header here , permissive CORS there , no rate limiting , no CSRF token , SQL query string concatenation - each one is a CVE waiting to happen
Express gives you the rope. These packages help you not hang yourself with it. But they're only effective if you configure them correctly. Default configs protect against the lazy attacker. Customize for your threat model
Helmet.js - security headers¶
Helmet sets HTTP security headers that block entire classes of attacks
npm install helmet
const helmet = require('helmet')
app.use(helmet()) // sets 15+ security headers with safe defaults
What Helmet sets:
Content-Security-Policy- prevents XSS and data injectionX-Content-Type-Options- prevents MIME type sniffingStrict-Transport-Security- enforces HTTPSX-Frame-Options- prevents clickjackingX-XSS-Protection- legacy XSS filter (mostly deprecated now)Referrer-Policy- controls referrer headerPermissions-Policy- restricts browser features- Removes
X-Powered-By- hides Express
Customize for your app:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'" , "'unsafe-inline'" , 'cdn.example.com'],
styleSrc: ["'self'" , "'unsafe-inline'"],
imgSrc: ["'self'" , 'data:' , 'images.example.com'],
connectSrc: ["'self'" , 'api.example.com'],
fontSrc: ["'self'" , 'fonts.googleapis.com'],
objectSrc: ["'none'"],
upgradeInsecureRequests: []
}
},
hsts: {
maxAge: 63072000, // 2 years
includeSubDomains: true,
preload: true
}
}))
CORS configuration¶
Cross-Origin Resource Sharing - controls which origins can read your responses
npm install cors
const cors = require('cors')
// WIDE OPEN - for development only
app.use(cors())
// RESTRICTED - for production
const corsOptions = {
origin: [
'https://yourapp.com',
'https://admin.yourapp.com'
],
methods: ['GET' , 'POST' , 'PUT' , 'DELETE'],
allowedHeaders: ['Content-Type' , 'Authorization'],
credentials: true, // allow cookies/auth headers
maxAge: 86400 // cache preflight for 24 hours
}
app.use(cors(corsOptions))
// PER ROUTE
app.get('/api/public' , cors() , handler)
app.post('/api/private' , cors(corsOptions) , handler)
// NEVER do this:
// app.use(cors({ origin: true })) // reflects origin - dangerous
// app.use(cors({ origin: '*' })) // allows every origin
rate limiting¶
Prevent brute force , DoS , and scraping
npm install express-rate-limit
const rateLimit = require('express-rate-limit')
// global rate limit
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false, // Disable X-RateLimit-* headers
message: { error: 'Too many requests , try again later' }
})
app.use(globalLimiter)
// strict rate limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 attempts per 15 minutes
message: { error: 'Too many login attempts' }
})
app.use('/login' , authLimiter)
app.use('/register' , authLimiter)
SQL injection prevention¶
Parameterized queries. Every time. No exceptions
// DANGEROUS - string concatenation
const query = `SELECT * FROM users WHERE id = ${req.params.id}` // SQLi heaven
// SAFE - parameterized query with pg (PostgreSQL)
const { Pool } = require('pg')
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
app.get('/users/:id' , async (req , res) => {
const result = await pool.query(
'SELECT * FROM users WHERE id = $1',
[req.params.id]
)
res.json(result.rows)
})
// SAFE - with mysql2
const mysql = require('mysql2/promise')
const connection = await mysql.createConnection(process.env.DATABASE_URL)
const [rows] = await connection.execute(
'SELECT * FROM users WHERE id = ?',
[req.params.id]
)
XSS prevention¶
Reflected and stored XSS are the most common web vulnerabilities
// 1. Helmet's CSP blocks inline scripts
app.use(helmet.contentSecurityPolicy({
directives: {
scriptSrc: ["'self'"] // no inline scripts , no eval
}
}))
// 2. Escape output in templates - always use <%= %> not <%- %>
// In EJS: <%= user.name %> (escaped)
// In EJS: <%- user.name %> (raw - XSS if user-controlled)
// 3. Sanitize user input
const createDOMPurify = require('dompurify')
const { JSDOM } = require('jsdom')
const window = new JSDOM('').window
const DOMPurify = createDOMPurify(window)
const clean = DOMPurify.sanitize(req.body.htmlContent)
CSRF prevention¶
Cross-Site Request Forgery - making authenticated users perform actions they didn't intend
npm install csrf-csrf
const { doubleCsrf } = require('csrf-csrf')
const { generateToken , doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: 'csrf-token',
cookieOptions: {
httpOnly: true,
sameSite: 'strict',
secure: true
},
size: 64,
getTokenFromRequest: (req) => req.headers['x-csrf-token']
})
app.use(doubleCsrfProtection)
// get CSRF token
app.get('/csrf-token' , (req , res) => {
res.json({ csrfToken: generateToken(req , res) })
})
// protected routes
app.post('/transfer' , (req , res) => {
// CSRF protection auto-validates
res.json({ transferred: true })
})
security checklist¶
// 1. Helmet - security headers
app.use(helmet())
// 2. CORS - restrict origins
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }))
// 3. Rate limiting
app.use(globalLimiter)
app.use('/auth' , authLimiter)
// 4. Body size limits
app.use(express.json({ limit: '10kb' }))
app.use(express.urlencoded({ limit: '10kb' }))
// 5. CSRF protection
app.use(doubleCsrfProtection)
// 6. Hide Express
app.disable('x-powered-by')
// 7. Trust proxy (if behind Nginx/Cloudflare)
app.set('trust proxy', 1)
// 8. HTTP parameter pollution protection
// Use express-validator's body() or a dedicated HPP middleware
prerequisites¶
express_11_auth.md - Passport strategies , bcrypt , JWT , protected routes
next → express_13_validation.md