Why API Security Is Different
APIs are the backbone of modern applications. Unlike traditional web pages where a human interacts through a browser, APIs are consumed by code — mobile apps, SPAs, third-party integrations, and other services. This changes the threat model:
+----------------------------+----------------------------------------------+
| Traditional Web App | API |
+----------------------------+----------------------------------------------+
| Browser enforces some | No browser — clients are code |
| security (CORS, cookies) | |
+----------------------------+----------------------------------------------+
| Users interact via forms | Requests crafted programmatically |
+----------------------------+----------------------------------------------+
| Rate naturally limited by | Automated requests — thousands per second |
| human typing speed | |
+----------------------------+----------------------------------------------+
| Errors shown to humans | Error details parsed by attackers |
+----------------------------+----------------------------------------------+
| Session-based auth common | Token-based auth common |
+----------------------------+----------------------------------------------+
Every API endpoint is an attack surface. Securing APIs requires explicit protection at every layer — you cannot rely on browser-enforced security mechanisms.
API Request Lifecycle — Security Checkpoints:
Client Request
|
v
[HTTPS Enforcement] -- Is the connection encrypted?
|
v
[Rate Limiting] -- Too many requests from this source?
|
v
[Authentication] -- Who is making this request?
|
v
[Authorization] -- Is this user allowed to do this?
|
v
[Input Validation] -- Is the request data valid and safe?
|
v
[Business Logic] -- Process the request
|
v
[Output Encoding] -- Is the response safe?
|
v
[Error Handling] -- Does the error reveal internal details?
|
v
Response
1. Rate Limiting
Rate limiting prevents abuse by restricting how many requests a client can make in a given time window. Without it, attackers can brute-force credentials, scrape data, or DDoS your service.
Rate Limiting Strategies
+--------------------+------------------------------------------+--------------------+
| Strategy | How It Works | Best For |
+--------------------+------------------------------------------+--------------------+
| Fixed window | Count requests per time window | Simple APIs |
| Sliding window | Rolling count over time period | Most APIs |
| Token bucket | Tokens replenish at fixed rate | Burst-tolerant |
| Leaky bucket | Requests processed at constant rate | Smooth throughput |
+--------------------+------------------------------------------+--------------------+
Implementation with Express
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');
const redisClient = redis.createClient({ url: process.env.REDIS_URL });
// General API rate limit
const apiLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false,
message: { error: 'Too many requests, try again later' },
keyGenerator: (req) => {
// Use authenticated user ID if available, otherwise IP
return req.user?.id || req.ip;
},
});
// Strict rate limit for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 attempts per 15 minutes
message: { error: 'Too many login attempts, try again in 15 minutes' },
keyGenerator: (req) => {
// Rate limit by IP AND email to prevent distributed attacks
return `${req.ip}:${req.body.email || 'unknown'}`;
},
skipSuccessfulRequests: true, // Don't count successful logins
});
// Apply rate limits
app.use('/api/', apiLimiter);
app.use('/api/login', authLimiter);
app.use('/api/forgot-password', authLimiter);
Custom Rate Limiter (Without Library)
const rateLimitStore = new Map();
function createRateLimiter(maxRequests, windowMs) {
return (req, res, next) => {
const key = req.user?.id || req.ip;
const now = Date.now();
if (!rateLimitStore.has(key)) {
rateLimitStore.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
const record = rateLimitStore.get(key);
if (now > record.resetAt) {
// Window expired, reset
record.count = 1;
record.resetAt = now + windowMs;
return next();
}
record.count++;
if (record.count > maxRequests) {
const retryAfter = Math.ceil((record.resetAt - now) / 1000);
res.setHeader('Retry-After', retryAfter);
return res.status(429).json({
error: 'Rate limit exceeded',
retryAfter,
});
}
// Set rate limit headers
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', maxRequests - record.count);
res.setHeader('X-RateLimit-Reset', Math.ceil(record.resetAt / 1000));
next();
};
}
// Usage
app.use('/api/', createRateLimiter(100, 15 * 60 * 1000));
2. Input Validation and Sanitization
Never trust client input. Every request parameter, header, and body field must be validated before processing. Use a schema validation library — do not write validation logic manually for complex objects.
Schema Validation with Zod
const { z } = require('zod');
// Define schemas
const createUserSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(12).max(128),
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s'-]+$/),
age: z.number().int().min(13).max(150).optional(),
});
const updateArticleSchema = z.object({
title: z.string().min(1).max(200).optional(),
body: z.string().min(1).max(50000).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
published: z.boolean().optional(),
});
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).max(1000).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
});
// Validation middleware factory
function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
req.validatedBody = result.data;
next();
};
}
function validateQuery(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({
error: 'Invalid query parameters',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
req.validatedQuery = result.data;
next();
};
}
// Usage
app.post('/api/users', validateBody(createUserSchema), async (req, res) => {
const { email, password, name } = req.validatedBody;
// Safe to use — validated and typed
const user = await createUser({ email, password, name });
res.json(user);
});
app.get('/api/articles', validateQuery(paginationSchema), async (req, res) => {
const { page, limit, sort } = req.validatedQuery;
const articles = await getArticles({ page, limit, sort });
res.json(articles);
});
Sanitization for Specific Contexts
const validator = require('validator');
// Sanitize different input types
function sanitizeInput(input, type) {
switch (type) {
case 'string':
return validator.trim(validator.escape(input));
case 'email':
return validator.normalizeEmail(input);
case 'url':
if (!validator.isURL(input, { protocols: ['http', 'https'] })) {
throw new Error('Invalid URL');
}
return input;
case 'integer':
if (!validator.isInt(String(input))) {
throw new Error('Invalid integer');
}
return parseInt(input, 10);
case 'uuid':
if (!validator.isUUID(input)) {
throw new Error('Invalid UUID');
}
return input;
default:
throw new Error(`Unknown type: ${type}`);
}
}
SQL Injection Prevention
Always use parameterized queries. Never concatenate user input into SQL strings.
// VULNERABLE — SQL injection
app.get('/api/users/:id', async (req, res) => {
const result = await db.query(
`SELECT * FROM users WHERE id = '${req.params.id}'`
// Attacker: /api/users/1' OR '1'='1' --
// Becomes: SELECT * FROM users WHERE id = '1' OR '1'='1' --'
);
res.json(result);
});
// SAFE — Parameterized query
app.get('/api/users/:id', async (req, res) => {
const result = await db.query(
'SELECT * FROM users WHERE id = $1',
[req.params.id]
);
res.json(result);
});
// SAFE — Using an ORM (Prisma example)
app.get('/api/users/:id', async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
});
res.json(user);
});
NoSQL Injection Prevention
// VULNERABLE — MongoDB operator injection
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// Attacker sends: { email: { $gt: "" }, password: { $gt: "" } }
// This matches the first user in the database
const user = await db.collection('users').findOne({ email, password });
res.json(user);
});
// SAFE — Validate types before querying
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
if (typeof email !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input types' });
}
const user = await db.collection('users').findOne({
email: String(email), // Force string type
});
// Then verify password with bcrypt (never query password directly)
if (user && await bcrypt.compare(password, user.passwordHash)) {
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
3. Output Encoding
API responses must not contain unencoded user data that could be interpreted as code in the consuming application.
// Set proper Content-Type to prevent MIME sniffing
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
// JSON responses — ensure Content-Type is correct
app.get('/api/user/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
// Express res.json() sets Content-Type: application/json automatically
// But be explicit if using res.send()
res.setHeader('Content-Type', 'application/json');
res.json({
id: user.id,
name: user.name,
// Don't include sensitive fields
// passwordHash: user.passwordHash, // NEVER
// resetToken: user.resetToken, // NEVER
});
});
// Filter sensitive fields from all responses
function sanitizeUserResponse(user) {
const { passwordHash, resetToken, mfaSecret, ...safe } = user;
return safe;
}
// Prevent JSON array responses (historical vulnerability)
// Some older browsers could interpret top-level JSON arrays as JavaScript
app.get('/api/articles', async (req, res) => {
const articles = await db.articles.findAll();
// Wrap in object instead of returning bare array
res.json({ data: articles, count: articles.length });
});
4. Error Handling (No Information Leaks)
Error responses must not expose internal details like stack traces, database schemas, file paths, or technology versions. These details help attackers understand your system.
// VULNERABLE — Exposes internal details
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
res.json(user);
} catch (err) {
// Exposes: database type, table names, query structure, file paths
res.status(500).json({
error: err.message,
stack: err.stack,
query: err.query,
});
}
});
// SAFE — Generic error response with internal logging
const { randomUUID } = require('crypto');
function errorHandler(err, req, res, next) {
const errorId = randomUUID();
// Log full error internally (for debugging)
console.error({
errorId,
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: req.user?.id,
timestamp: new Date().toISOString(),
});
// Determine status code
const statusCode = err.statusCode || err.status || 500;
// Return safe error to client
if (statusCode >= 500) {
// Server errors — never expose details
res.status(statusCode).json({
error: 'Internal server error',
errorId, // Allow user to reference this in support requests
});
} else {
// Client errors (4xx) — safe to provide detail
res.status(statusCode).json({
error: err.publicMessage || 'Bad request',
...(err.details ? { details: err.details } : {}),
errorId,
});
}
}
// Custom error class for controlled errors
class ApiError extends Error {
constructor(statusCode, publicMessage, details = null) {
super(publicMessage);
this.statusCode = statusCode;
this.publicMessage = publicMessage;
this.details = details;
}
}
// Usage in routes
app.get('/api/articles/:id', async (req, res, next) => {
try {
const article = await db.articles.findById(req.params.id);
if (!article) {
throw new ApiError(404, 'Article not found');
}
res.json(article);
} catch (err) {
next(err); // Pass to error handler
}
});
// Register error handler last
app.use(errorHandler);
Hide Server Headers
const helmet = require('helmet');
app.use(helmet());
// Specifically hide the X-Powered-By header
app.disable('x-powered-by');
// Remove Server header (if possible — depends on reverse proxy)
app.use((req, res, next) => {
res.removeHeader('X-Powered-By');
next();
});
5. Authentication and Authorization for APIs
API Key Authentication
For service-to-service communication or third-party integrations:
// API key validation middleware
function validateApiKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Hash the key and look up in database
// (Store hashed keys, not plain text)
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
const keyRecord = await db.apiKeys.findByHash(keyHash);
if (!keyRecord || keyRecord.revoked) {
return res.status(401).json({ error: 'Invalid API key' });
}
// Check key permissions
req.apiClient = {
clientId: keyRecord.clientId,
permissions: keyRecord.permissions,
};
// Log usage
await db.apiKeyUsage.log({
keyId: keyRecord.id,
endpoint: req.path,
method: req.method,
ip: req.ip,
timestamp: new Date(),
});
next();
}
JWT Authentication for APIs
const jwt = require('jsonwebtoken');
function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Bearer token required' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Explicitly set algorithm
issuer: 'myapp.com', // Validate issuer
audience: 'api', // Validate audience
});
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
};
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
6. HTTPS Enforcement
All API communication must use HTTPS. Reject or redirect HTTP requests.
// Middleware to enforce HTTPS
function requireHTTPS(req, res, next) {
// Check for HTTPS (including behind reverse proxy)
if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
return next();
}
// For APIs, reject rather than redirect
// (redirects can cause silent credential leaks)
return res.status(403).json({
error: 'HTTPS required',
detail: 'This API only accepts HTTPS connections',
});
}
app.use(requireHTTPS);
// Set HSTS header
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
next();
});
7. Request Size Limits
Prevent denial of service via oversized payloads:
const express = require('express');
// Limit JSON body size
app.use(express.json({ limit: '1mb' }));
// Limit URL-encoded body size
app.use(express.urlencoded({ limit: '1mb', extended: true }));
// Different limits for different routes
app.use('/api/upload', express.json({ limit: '10mb' }));
app.use('/api/', express.json({ limit: '100kb' }));
8. Logging and Monitoring
// Security event logging
function securityLog(event) {
console.log(JSON.stringify({
...event,
timestamp: new Date().toISOString(),
service: 'api',
}));
}
// Log authentication failures
app.post('/api/login', async (req, res) => {
try {
const user = await authenticate(req.body);
securityLog({
type: 'AUTH_SUCCESS',
userId: user.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
res.json({ success: true });
} catch {
securityLog({
type: 'AUTH_FAILURE',
email: req.body.email,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Log authorization failures
function requirePermission(permission) {
return (req, res, next) => {
if (!hasPermission(req.user, permission)) {
securityLog({
type: 'AUTHZ_FAILURE',
userId: req.user?.id,
permission,
path: req.path,
method: req.method,
ip: req.ip,
});
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Log rate limit violations
function onRateLimitExceeded(req, res) {
securityLog({
type: 'RATE_LIMIT',
ip: req.ip,
path: req.path,
userId: req.user?.id,
});
}
9. API Versioning (Security Consideration)
Outdated API versions may have known vulnerabilities. Plan for deprecation:
// Version-aware middleware
function apiVersion(req, res, next) {
const version = req.headers['api-version'] || req.path.match(/^\/api\/(v\d+)\//)?.[1] || 'v1';
const deprecatedVersions = ['v1'];
const sunsetVersions = [];
if (sunsetVersions.includes(version)) {
return res.status(410).json({
error: `API ${version} is no longer available`,
migration: 'https://docs.example.com/migration',
});
}
if (deprecatedVersions.includes(version)) {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
res.setHeader('Link', '<https://docs.example.com/migration>; rel="deprecation"');
}
req.apiVersion = version;
next();
}
Complete API Security Checklist
+------+----------------------------------------------+----------+
| # | Check | Status |
+------+----------------------------------------------+----------+
| | TRANSPORT | |
| 1 | HTTPS enforced (HTTP rejected, not redirected)| [ ] |
| 2 | TLS 1.2+ with strong cipher suites | [ ] |
| 3 | HSTS header set | [ ] |
+------+----------------------------------------------+----------+
| | AUTHENTICATION | |
| 4 | Auth required on all non-public endpoints | [ ] |
| 5 | JWT algorithm explicitly set in verify() | [ ] |
| 6 | API keys hashed before storage | [ ] |
| 7 | Token expiration enforced | [ ] |
+------+----------------------------------------------+----------+
| | AUTHORIZATION | |
| 8 | Authorization checked on every endpoint | [ ] |
| 9 | Resource-level ownership verified | [ ] |
| 10 | No IDOR vulnerabilities | [ ] |
+------+----------------------------------------------+----------+
| | INPUT | |
| 11 | All input validated with schema (Zod/Joi) | [ ] |
| 12 | SQL injection prevented (parameterized queries)| [ ] |
| 13 | NoSQL injection prevented (type checking) | [ ] |
| 14 | Request body size limited | [ ] |
| 15 | File uploads validated (type, size) | [ ] |
+------+----------------------------------------------+----------+
| | OUTPUT | |
| 16 | Content-Type set correctly on responses | [ ] |
| 17 | X-Content-Type-Options: nosniff | [ ] |
| 18 | Sensitive fields stripped from responses | [ ] |
+------+----------------------------------------------+----------+
| | ERROR HANDLING | |
| 19 | No stack traces in production responses | [ ] |
| 20 | No database/query details in errors | [ ] |
| 21 | Error IDs for support reference | [ ] |
| 22 | Server header hidden (X-Powered-By removed) | [ ] |
+------+----------------------------------------------+----------+
| | RATE LIMITING | |
| 23 | General rate limit on all endpoints | [ ] |
| 24 | Strict rate limit on auth endpoints | [ ] |
| 25 | Rate limit headers returned (Retry-After) | [ ] |
+------+----------------------------------------------+----------+
| | CORS | |
| 26 | CORS origin is allowlist (not wildcard) | [ ] |
| 27 | CORS credentials configured correctly | [ ] |
| 28 | Preflight (OPTIONS) handled | [ ] |
+------+----------------------------------------------+----------+
| | LOGGING | |
| 29 | Auth failures logged | [ ] |
| 30 | Rate limit violations logged | [ ] |
| 31 | Authorization failures logged | [ ] |
| 32 | No sensitive data in logs (passwords, tokens) | [ ] |
+------+----------------------------------------------+----------+
Summary
API security requires explicit protection at every layer because APIs lack the browser's built-in security mechanisms.
Key takeaways:
- Rate limit everything — General limits on all endpoints, strict limits on authentication.
- Validate all input — Use schema validation (Zod, Joi). Never trust client data.
- Parameterize queries — Prevent SQL and NoSQL injection at the query level.
- Encode output — Set correct Content-Type, strip sensitive fields, prevent MIME sniffing.
- Handle errors safely — Never expose stack traces, database details, or internal paths.
- Authenticate and authorize — Every endpoint must verify identity and permissions.
- Enforce HTTPS — Reject (do not redirect) HTTP API requests.
- Log security events — Authentication failures, authorization denials, and rate limit violations.
No API is secure by default. Security must be explicitly implemented, tested, and maintained.