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.
LayerWhat it protects against
Rate limitingBrute force, DoS, scraping
Helmet (HTTP headers)XSS, clickjacking, MIME sniffing
CORSCross-origin data theft
Input validationInjection, data corruption
Authentication/AuthorizationUnauthorized access
Request size limitsMemory exhaustion, payload bombs
HTTPSEavesdropping, 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)ProtectionDefault
Content-Security-PolicyPrevents XSS by controlling which scripts/styles can loadRestrictive default
X-Content-Type-Options: nosniffPrevents MIME type sniffingEnabled
X-Frame-Options: SAMEORIGINPrevents clickjacking (iframe embedding)Enabled
X-XSS-Protection: 0Disables buggy browser XSS filter (CSP is better)Enabled
Strict-Transport-SecurityForces HTTPS for future requests (HSTS)Enabled
X-DNS-Prefetch-Control: offPrevents DNS prefetching leaksEnabled
X-Download-Options: noopenPrevents IE from executing downloadsEnabled
X-Permitted-Cross-Domain-Policies: noneRestricts Adobe Flash/AcrobatEnabled
Referrer-Policy: no-referrerControls Referer header leaksEnabled

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

TypeHow it worksExample
Stored XSSMalicious script saved in DB, rendered to other usersComment with <script>steal(cookies)</script>
Reflected XSSScript in URL reflected in response?search=<script>alert(1)</script>
DOM-based XSSClient-side JS inserts unescaped data into DOMinnerHTML = 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

StrategyHow it works
SameSite cookiesCookie only sent with same-site requests
CSRF tokensUnique token in form/header, validated server-side
Custom headersRequire X-Requested-With — browsers block cross-origin custom headers
JWT in Authorization headerNot 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.

RankRiskExpress mitigation
API1Broken Object-Level AuthorizationCheck ownership in every route (user.id === resource.ownerId)
API2Broken AuthenticationStrong JWT, bcrypt, rate-limit login
API3Broken Object Property-Level AuthorizationOnly return fields the user should see (.select())
API4Unrestricted Resource ConsumptionRate limiting, pagination limits, body size limits
API5Broken Function-Level AuthorizationRole-based middleware (requireRole('admin'))
API6Unrestricted Access to Sensitive Business FlowsCAPTCHA, rate limit account creation, bot detection
API7Server-Side Request Forgery (SSRF)Validate and whitelist external URLs
API8Security MisconfigurationHelmet, disable X-Powered-By, no stack traces in prod
API9Improper Inventory ManagementDocument all endpoints, disable unused routes
API10Unsafe Consumption of APIsValidate 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

  1. Defense in depth: no single security measure is sufficient — layer rate limiting, headers, CORS, validation, and sanitization.
  2. Rate limit aggressively on auth endpoints (10 attempts/15 min) and more leniently on general endpoints (100 req/15 min).
  3. Helmet sets critical security headers with one line; customize CSP and HSTS for your specific needs.
  4. CORS must be strict in production — never use cors() with no options; whitelist specific origins.
  5. express.json({ limit: '10kb' }) prevents payload bombs; express-mongo-sanitize prevents NoSQL injection.
  6. Never expose internal error details in production — stack traces are a gift to attackers.

Explain-It Challenge

Explain without notes:

  1. Why do auth endpoints need stricter rate limits than regular API endpoints?
  2. Walk through how NoSQL injection works against a MongoDB login query and how express-mongo-sanitize prevents it.
  3. 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 ->