Episode 3 — NodeJS MongoDB Backend Architecture / 3.9 — REST API Development
3.9.f — API Security
In one sentence: Securing a REST API requires defense in depth — rate limiting, helmet headers, CORS configuration, input validation, injection prevention, and request size limits work together to protect against the OWASP Top 10 and real-world attack vectors.
Navigation: <- 3.9.e — Input Validation and Sanitization | 3.9 Exercise Questions ->
1. The Security Mindset
Every request is hostile until proven otherwise.
Every input is malicious until validated.
Every error message is a potential information leak.
| Layer | What it protects against |
|---|---|
| Rate limiting | Brute force, DoS, scraping |
| Helmet (HTTP headers) | XSS, clickjacking, MIME sniffing |
| CORS | Cross-origin data theft |
| Input validation | Injection, data corruption |
| Authentication/Authorization | Unauthorized access |
| Request size limits | Memory exhaustion, payload bombs |
| HTTPS | Eavesdropping, tampering |
2. Rate Limiting with express-rate-limit
Rate limiting prevents a single client from overwhelming your API.
npm install express-rate-limit
Basic configuration
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false, // Disable X-RateLimit-* headers
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many requests, please try again later',
retryAfter: 900
}
}
});
app.use('/api/', apiLimiter);
Different limits for different routes
Auth endpoints need stricter limits because they are prime targets for brute-force attacks.
// Strict limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // only 10 login attempts per 15 min
message: {
error: {
code: 'AUTH_RATE_LIMITED',
message: 'Too many login attempts. Please try again in 15 minutes.'
}
},
skipSuccessfulRequests: true // only count failed attempts
});
// Account creation limit
const createAccountLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 accounts per hour per IP
message: {
error: {
code: 'CREATION_RATE_LIMITED',
message: 'Too many accounts created. Please try again later.'
}
}
});
// Apply different limits to different routes
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', createAccountLimiter);
app.use('/api/', apiLimiter); // general limit for everything else
Rate limit response headers
RateLimit-Limit: 100
RateLimit-Remaining: 73
RateLimit-Reset: 1714567890
Clients should read these headers to implement backoff strategies.
3. Helmet: Security Headers
helmet sets various HTTP response headers to protect against common web vulnerabilities.
npm install helmet
Basic usage
const helmet = require('helmet');
// Apply all default protections
app.use(helmet());
What each header does
| Header (set by helmet) | Protection | Default |
|---|---|---|
| Content-Security-Policy | Prevents XSS by controlling which scripts/styles can load | Restrictive default |
| X-Content-Type-Options: nosniff | Prevents MIME type sniffing | Enabled |
| X-Frame-Options: SAMEORIGIN | Prevents clickjacking (iframe embedding) | Enabled |
| X-XSS-Protection: 0 | Disables buggy browser XSS filter (CSP is better) | Enabled |
| Strict-Transport-Security | Forces HTTPS for future requests (HSTS) | Enabled |
| X-DNS-Prefetch-Control: off | Prevents DNS prefetching leaks | Enabled |
| X-Download-Options: noopen | Prevents IE from executing downloads | Enabled |
| X-Permitted-Cross-Domain-Policies: none | Restricts Adobe Flash/Acrobat | Enabled |
| Referrer-Policy: no-referrer | Controls Referer header leaks | Enabled |
Custom helmet configuration
app.use(helmet({
// Content Security Policy — most important header
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"]
}
},
// HSTS: tell browsers to always use HTTPS
strictTransportSecurity: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true
},
// Referrer policy
referrerPolicy: {
policy: 'strict-origin-when-cross-origin'
}
}));
// For APIs that don't serve HTML, disable CSP (not needed for JSON-only APIs)
app.use(helmet({
contentSecurityPolicy: false // only if you serve pure JSON, no HTML
}));
4. CORS Security
CORS (Cross-Origin Resource Sharing) controls which domains can access your API from a browser.
npm install cors
Insecure (avoid)
// DO NOT DO THIS IN PRODUCTION
app.use(cors()); // allows ALL origins — wide open
Secure: strict origin configuration
const cors = require('cors');
const allowedOrigins = [
'https://myapp.com',
'https://www.myapp.com',
'https://admin.myapp.com'
];
// Add localhost only in development
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:3000', 'http://localhost:5173');
}
const corsOptions = {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // allow cookies/auth headers
maxAge: 86400 // cache preflight for 24 hours
};
app.use(cors(corsOptions));
Per-route CORS
// Public route: allow all origins
app.get('/api/public/status', cors(), (req, res) => {
res.json({ status: 'ok' });
});
// Protected route: strict origins only
app.use('/api/admin', cors(corsOptions), adminRouter);
5. XSS (Cross-Site Scripting) Prevention
XSS happens when an attacker injects malicious scripts that run in other users' browsers.
Types of XSS
| Type | How it works | Example |
|---|---|---|
| Stored XSS | Malicious script saved in DB, rendered to other users | Comment with <script>steal(cookies)</script> |
| Reflected XSS | Script in URL reflected in response | ?search=<script>alert(1)</script> |
| DOM-based XSS | Client-side JS inserts unescaped data into DOM | innerHTML = userInput |
Prevention layers
// Layer 1: Input sanitization (covered in 3.9.e)
const { body } = require('express-validator');
body('comment').trim().escape(); // HTML-encode dangerous characters
// Layer 2: Helmet CSP headers (covered above)
app.use(helmet());
// Layer 3: Use mongo-sanitize to prevent NoSQL injection
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize()); // strips $ and . from req.body, req.query, req.params
// Layer 4: Never render user input as raw HTML
// In EJS: <%= userInput %> (escaped — SAFE)
// <%- userInput %> (unescaped — DANGEROUS)
6. CSRF (Cross-Site Request Forgery) Prevention
CSRF tricks a logged-in user's browser into making unwanted requests to your API.
How CSRF works
1. Alice logs into bank.com (cookie set)
2. Alice visits evil.com
3. evil.com has: <form action="bank.com/transfer" method="POST">
4. Browser sends the form WITH Alice's bank.com cookies
5. Bank processes the transfer thinking it's Alice
Prevention strategies for REST APIs
| Strategy | How it works |
|---|---|
| SameSite cookies | Cookie only sent with same-site requests |
| CSRF tokens | Unique token in form/header, validated server-side |
| Custom headers | Require X-Requested-With — browsers block cross-origin custom headers |
| JWT in Authorization header | Not auto-sent like cookies — CSRF-immune |
// Strategy 1: SameSite cookies (simplest)
res.cookie('session', token, {
httpOnly: true, // not accessible via JavaScript
secure: true, // only sent over HTTPS
sameSite: 'strict', // never sent cross-site (or 'lax' for top-level navigations)
maxAge: 24 * 60 * 60 * 1000 // 1 day
});
// Strategy 2: Require custom header (effective for AJAX-only APIs)
const csrfProtection = (req, res, next) => {
// Browsers block cross-origin custom headers in simple requests
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
if (!req.headers['x-requested-with']) {
return res.status(403).json({
error: { code: 'CSRF_FAILED', message: 'Missing X-Requested-With header' }
});
}
}
next();
};
// Strategy 3: JWT in Authorization header (most common for SPAs)
// Since JWTs are stored in memory/localStorage (not cookies),
// they are NOT automatically sent by the browser, making CSRF impossible
7. SQL/NoSQL Injection Prevention
NoSQL injection in MongoDB
// VULNERABLE: attacker sends { "$gt": "" } as password
// POST /login body: { "email": "admin@test.com", "password": { "$gt": "" } }
// This becomes: User.findOne({ email: "admin@test.com", password: { $gt: "" } })
// Which matches ANY non-empty password!
// PREVENTION 1: express-mongo-sanitize
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize());
// Strips $ and . from user input, preventing operator injection
// PREVENTION 2: Explicit type checking
app.post('/login', (req, res) => {
const { email, password } = req.body;
// Ensure types are strings, not objects
if (typeof email !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: { message: 'Invalid input types' } });
}
// Now safe to query
const user = await User.findOne({ email });
const isMatch = await bcrypt.compare(password, user.password);
// ...
});
// PREVENTION 3: Mongoose schema validation
// Mongoose schemas enforce types, so { "$gt": "" } would fail String validation
const userSchema = new mongoose.Schema({
email: { type: String, required: true },
password: { type: String, required: true } // rejects non-string values
});
SQL injection (if using SQL databases)
// VULNERABLE: string concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// Attacker sends: ' OR '1'='1
// SAFE: parameterized queries
const query = 'SELECT * FROM users WHERE email = ?';
db.execute(query, [email]);
8. DoS/DDoS Mitigation
Request body size limits
// Limit JSON body to 10kb — prevents payload bombs
app.use(express.json({ limit: '10kb' }));
// Limit URL-encoded body
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Different limits for different routes
app.use('/api/upload', express.json({ limit: '5mb' })); // file metadata
app.use('/api/', express.json({ limit: '10kb' })); // general API
Request timeout
// Timeout middleware
const timeout = require('connect-timeout');
app.use(timeout('30s')); // 30-second timeout for all requests
app.use((req, res, next) => {
if (!req.timedout) next();
});
// Or manual timeout per route
app.get('/api/heavy-report', async (req, res) => {
const timer = setTimeout(() => {
if (!res.headersSent) {
res.status(503).json({
error: { code: 'TIMEOUT', message: 'Request took too long' }
});
}
}, 10000);
try {
const data = await generateReport();
clearTimeout(timer);
res.json({ data });
} catch (err) {
clearTimeout(timer);
throw err;
}
});
Query complexity limits
// Prevent expensive MongoDB queries
app.get('/api/users', (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 20, 100); // max 100
const page = parseInt(req.query.page) || 1;
// Prevent deep population attacks
const allowedPopulate = ['posts', 'profile'];
const populate = req.query.populate?.split(',').filter(p => allowedPopulate.includes(p));
const users = await User.find()
.skip((page - 1) * limit)
.limit(limit)
.populate(populate || []);
res.json({ data: users });
});
9. Complete Security Setup
Here is a production Express application with all security layers applied.
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const app = express();
// =============================================
// LAYER 1: Security Headers
// =============================================
app.use(helmet({
contentSecurityPolicy: false // disable for JSON-only APIs
}));
// =============================================
// LAYER 2: CORS
// =============================================
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin)) return cb(null, true);
cb(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
}));
// =============================================
// LAYER 3: Rate Limiting
// =============================================
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false
}));
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
skipSuccessfulRequests: true
}));
// =============================================
// LAYER 4: Body Parsing with Size Limits
// =============================================
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// =============================================
// LAYER 5: NoSQL Injection Prevention
// =============================================
app.use(mongoSanitize());
// =============================================
// LAYER 6: Routes (with per-route validation)
// =============================================
app.use('/api/v1/auth', require('./routes/v1/auth'));
app.use('/api/v1/users', require('./routes/v1/users'));
app.use('/api/v1/posts', require('./routes/v1/posts'));
// =============================================
// LAYER 7: Error Handler (no stack traces in prod)
// =============================================
app.use((err, req, res, next) => {
console.error(err);
res.status(err.statusCode || 500).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production'
? 'Something went wrong'
: err.message
}
});
});
module.exports = app;
10. OWASP Top 10 for API Security
The OWASP API Security Top 10 (2023) lists the most critical API security risks.
| Rank | Risk | Express mitigation |
|---|---|---|
| API1 | Broken Object-Level Authorization | Check ownership in every route (user.id === resource.ownerId) |
| API2 | Broken Authentication | Strong JWT, bcrypt, rate-limit login |
| API3 | Broken Object Property-Level Authorization | Only return fields the user should see (.select()) |
| API4 | Unrestricted Resource Consumption | Rate limiting, pagination limits, body size limits |
| API5 | Broken Function-Level Authorization | Role-based middleware (requireRole('admin')) |
| API6 | Unrestricted Access to Sensitive Business Flows | CAPTCHA, rate limit account creation, bot detection |
| API7 | Server-Side Request Forgery (SSRF) | Validate and whitelist external URLs |
| API8 | Security Misconfiguration | Helmet, disable X-Powered-By, no stack traces in prod |
| API9 | Improper Inventory Management | Document all endpoints, disable unused routes |
| API10 | Unsafe Consumption of APIs | Validate responses from third-party APIs |
11. Security Headers Checklist
Use this checklist before deploying any API to production.
[ ] helmet() applied
[ ] CORS restricted to known origins
[ ] Rate limiting on all routes (stricter on auth)
[ ] express.json({ limit: '10kb' }) — body size limit
[ ] express-mongo-sanitize applied
[ ] Input validation on every route (express-validator or Zod)
[ ] Passwords hashed with bcrypt (cost >= 12)
[ ] JWT secret in environment variable (not hardcoded)
[ ] HTTPS enforced (HSTS header)
[ ] Error responses hide internal details in production
[ ] X-Powered-By header removed (helmet does this)
[ ] Sensitive data not logged (passwords, tokens)
[ ] Database credentials in env vars, not in code
[ ] Dependencies regularly updated (npm audit)
[ ] 404 handler returns generic message (no path leaking)
// Quick security audit — add this to your test suite
describe('Security headers', () => {
it('should set security headers', async () => {
const res = await request(app).get('/api/v1/status');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(res.headers['x-powered-by']).toBeUndefined();
});
it('should reject oversized payloads', async () => {
const bigPayload = { data: 'x'.repeat(100000) };
const res = await request(app)
.post('/api/v1/users')
.send(bigPayload);
expect(res.status).toBe(413); // Payload Too Large
});
it('should rate-limit login attempts', async () => {
for (let i = 0; i < 11; i++) {
await request(app)
.post('/api/v1/auth/login')
.send({ email: 'test@test.com', password: 'wrong' });
}
const res = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'test@test.com', password: 'wrong' });
expect(res.status).toBe(429);
});
});
12. Key Takeaways
- Defense in depth: no single security measure is sufficient — layer rate limiting, headers, CORS, validation, and sanitization.
- Rate limit aggressively on auth endpoints (10 attempts/15 min) and more leniently on general endpoints (100 req/15 min).
- Helmet sets critical security headers with one line; customize CSP and HSTS for your specific needs.
- CORS must be strict in production — never use
cors()with no options; whitelist specific origins. express.json({ limit: '10kb' })prevents payload bombs;express-mongo-sanitizeprevents NoSQL injection.- Never expose internal error details in production — stack traces are a gift to attackers.
Explain-It Challenge
Explain without notes:
- Why do auth endpoints need stricter rate limits than regular API endpoints?
- Walk through how NoSQL injection works against a MongoDB login query and how
express-mongo-sanitizeprevents it. - Why is
cors()with no options dangerous in production, and how would you configure it securely?
Navigation: <- 3.9.e — Input Validation and Sanitization | 3.9 Exercise Questions ->