API Security Best Practices
Introduction
API security is critical for protecting user data and preventing attacks. This guide covers authentication, authorization, input validation, protection against common vulnerabilities (SQL injection, XSS, CSRF), rate limiting, and security best practices for production APIs.
1. OWASP API Security Top 10
OWASP API Security Top 10 (2023):
1. Broken Object Level Authorization (BOLA)
- Users can access objects they shouldn't
2. Broken Authentication
- Weak authentication implementation
3. Broken Object Property Level Authorization
- Mass assignment vulnerabilities
4. Unrestricted Resource Consumption
- No rate limiting, DDoS attacks
5. Broken Function Level Authorization
- Users can access admin functions
6. Unrestricted Access to Sensitive Business Flows
- Automated abuse (scalping, etc.)
7. Server Side Request Forgery (SSRF)
- API makes unvalidated requests
8. Security Misconfiguration
- Default passwords, verbose errors
9. Improper Inventory Management
- Undocumented endpoints, old versions
10. Unsafe Consumption of APIs
- Trusting third-party APIs without validation
2. Authentication & JWT Security
// Secure JWT implementation
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
// Strong secret (use env variable)
const JWT_SECRET = process.env.JWT_SECRET; // min 256 bits
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
// Generate tokens
function generateTokens(userId: string) {
const accessToken = jwt.sign(
{ userId, type: 'access' },
JWT_SECRET,
{
expiresIn: '15m', // Short-lived
algorithm: 'HS256',
issuer: 'my-api',
audience: 'my-app'
}
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
JWT_REFRESH_SECRET,
{
expiresIn: '7d',
algorithm: 'HS256',
issuer: 'my-api'
}
);
return { accessToken, refreshToken };
}
// Verify with proper error handling
function verifyToken(token: string) {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'my-api',
audience: 'my-app'
});
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error('Token expired');
} else if (error instanceof jwt.JsonWebTokenError) {
throw new Error('Invalid token');
}
throw error;
}
}
// Login endpoint with rate limiting
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;
// Input validation
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Find user
const user = await User.findOne({ email });
if (!user) {
// Generic error (don't reveal if user exists)
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password with timing-safe comparison
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
// Log failed attempt
await logFailedLogin(user.id, req.ip);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const { accessToken, refreshToken } = generateTokens(user.id);
// Store refresh token securely
await saveRefreshToken(user.id, refreshToken);
// Send tokens
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken, user: { id: user.id, email: user.email } });
});
3. Authorization & RBAC
// Role-Based Access Control
enum Role {
USER = 'user',
ADMIN = 'admin',
MODERATOR = 'moderator'
}
interface AuthRequest extends Request {
user?: {
id: string;
role: Role;
permissions: string[];
};
}
// Authorization middleware
function authorize(...allowedRoles: Role[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Permission-based authorization
function requirePermission(...permissions: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const hasPermission = permissions.every(p =>
req.user!.permissions.includes(p)
);
if (!hasPermission) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage
app.delete('/api/users/:id',
authenticate,
authorize(Role.ADMIN),
async (req, res) => {
// Only admins can delete users
}
);
app.post('/api/posts/:id/publish',
authenticate,
requirePermission('posts:publish'),
async (req, res) => {
// User needs specific permission
}
);
// Object-level authorization (BOLA protection)
app.get('/api/posts/:id', authenticate, async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
// Check ownership or permissions
if (post.authorId !== req.user!.id && !post.isPublic) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(post);
});
4. Input Validation & Sanitization
// Using Joi for validation
import Joi from 'joi';
const userSchema = Joi.object({
email: Joi.string().email().required(),
name: Joi.string().min(2).max(100).required(),
age: Joi.number().integer().min(13).max(120),
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.required()
.messages({
'string.pattern.base': 'Password must contain uppercase, lowercase, number and special character'
})
});
// Validation middleware
function validate(schema: Joi.Schema) {
return (req: Request, res: Response, next: NextFunction) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true // Remove unknown fields
});
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
return res.status(400).json({ errors });
}
req.body = value; // Use validated data
next();
};
}
// Usage
app.post('/api/users', validate(userSchema), async (req, res) => {
// req.body is validated and sanitized
const user = await User.create(req.body);
res.json(user);
});
// SQL Injection prevention with parameterized queries
// BAD - Vulnerable to SQL injection
const userId = req.params.id;
const query = `SELECT * FROM users WHERE id = '${userId}'`;
db.query(query); // NEVER DO THIS
// GOOD - Use parameterized queries
const userId = req.params.id;
const query = 'SELECT * FROM users WHERE id = $1';
db.query(query, [userId]);
// NoSQL Injection prevention
// BAD - MongoDB injection
const email = req.body.email;
User.findOne({ email: email }); // If email = { $ne: null }, returns any user
// GOOD - Validate and sanitize
const userSchema = Joi.object({
email: Joi.string().email().required()
});
const { value } = userSchema.validate({ email: req.body.email });
User.findOne({ email: value.email });
// XSS prevention - sanitize HTML
import DOMPurify from 'isomorphic-dompurify';
const sanitizedHtml = DOMPurify.sanitize(req.body.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});
5. Rate Limiting & DDoS Protection
// Express rate limiting
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379
});
// Global rate limit
const globalLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:global:'
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: req.rateLimit.resetTime
});
}
});
// Stricter limit for authentication endpoints
const authLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:auth:'
}),
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true // Don't count successful logins
});
// Per-user rate limiting
const createUserLimiter = (userId: string) => {
return rateLimit({
store: new RedisStore({
client: redis,
prefix: `rl:user:${userId}:`
}),
windowMs: 60 * 1000, // 1 minute
max: 10,
keyGenerator: () => userId
});
};
// Sliding window rate limiting (more accurate)
class SlidingWindowRateLimiter {
constructor(
private redis: Redis,
private maxRequests: number,
private windowMs: number
) {}
async isAllowed(key: string): Promise {
const now = Date.now();
const windowStart = now - this.windowMs;
// Remove old entries
await this.redis.zremrangebyscore(key, 0, windowStart);
// Count requests in window
const count = await this.redis.zcard(key);
if (count >= this.maxRequests) {
return false;
}
// Add current request
await this.redis.zadd(key, now, `${now}-${Math.random()}`);
await this.redis.expire(key, Math.ceil(this.windowMs / 1000));
return true;
}
}
// Usage
app.use('/api/', globalLimiter);
app.post('/api/login', authLimiter, loginHandler);
// Request size limiting
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
6. CORS & CSRF Protection
// CORS configuration
import cors from 'cors';
const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = [
'https://myapp.com',
'https://www.myapp.com'
];
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'],
maxAge: 86400 // 24 hours
};
app.use(cors(corsOptions));
// CSRF Protection with tokens
import csrf from 'csurf';
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict'
}
});
// Get CSRF token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// Protect state-changing operations
app.post('/api/posts', csrfProtection, async (req, res) => {
// CSRF token validated automatically
const post = await Post.create(req.body);
res.json(post);
});
// SameSite cookies (alternative CSRF protection)
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict', // or 'lax'
maxAge: 3600000
});
7. Security Headers
// Using Helmet for security headers
import helmet from 'helmet';
app.use(helmet());
// Custom security headers
app.use((req, res, next) => {
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// XSS protection
res.setHeader('X-Content-Type-Options', 'nosniff');
// Force HTTPS
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Content Security Policy
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:;"
);
// Referrer policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
res.setHeader('Permissions-Policy',
'geolocation=(), microphone=(), camera=()'
);
next();
});
// Hide server information
app.disable('x-powered-by');
8. API Key Management
// API key authentication
import crypto from 'crypto';
// Generate API key
function generateApiKey(): string {
return crypto.randomBytes(32).toString('hex');
}
// Hash API key for storage
function hashApiKey(apiKey: string): string {
return crypto.createHash('sha256').update(apiKey).digest('hex');
}
// Middleware to validate API key
async function validateApiKey(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Hash and lookup
const hashedKey = hashApiKey(apiKey);
const keyData = await ApiKey.findOne({ keyHash: hashedKey });
if (!keyData) {
return res.status(401).json({ error: 'Invalid API key' });
}
// Check if key is active
if (!keyData.isActive) {
return res.status(403).json({ error: 'API key deactivated' });
}
// Check rate limits for this key
const allowed = await checkRateLimit(keyData.id);
if (!allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
// Attach key data to request
req.apiKey = keyData;
// Log usage
await logApiKeyUsage(keyData.id, req.path);
next();
}
// API Key model
interface ApiKey {
id: string;
userId: string;
keyHash: string;
name: string;
isActive: boolean;
rateLimit: number;
permissions: string[];
createdAt: Date;
lastUsedAt: Date;
}
// Usage
app.get('/api/public/data', validateApiKey, async (req, res) => {
// Protected by API key
res.json({ data: 'sensitive info' });
});
9. Error Handling & Logging
// Don't leak sensitive information in errors
// BAD
app.get('/api/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message }); // Leaks info!
}
});
// GOOD
class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational: boolean = true
) {
super(message);
}
}
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// Log error with context
logger.error('API Error', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip,
userId: req.user?.id
});
// Send safe error to client
if (err instanceof AppError && err.isOperational) {
res.status(err.statusCode).json({
error: err.message
});
} else {
// Don't leak internal errors
res.status(500).json({
error: 'Internal server error'
});
}
});
// Structured logging
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Log security events
function logSecurityEvent(event: string, details: any) {
logger.warn('Security Event', {
event,
...details,
timestamp: new Date().toISOString()
});
}
10. Additional Security Measures
// HTTPS enforcement
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(`https://${req.hostname}${req.url}`);
}
next();
});
// Request ID for tracing
import { v4 as uuidv4 } from 'uuid';
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] as string || uuidv4();
res.setHeader('X-Request-ID', req.id);
next();
});
// Timeout handling
import timeout from 'connect-timeout';
app.use(timeout('30s'));
app.use((req, res, next) => {
if (!req.timedout) next();
});
// Parameter pollution prevention
import hpp from 'hpp';
app.use(hpp());
// Dependency security scanning
// package.json scripts
{
"scripts": {
"audit": "npm audit",
"audit:fix": "npm audit fix"
}
}
// Use Snyk or Dependabot for continuous monitoring
11. Security Checklist
β API Security Checklist:
- β Use HTTPS everywhere (TLS 1.3+)
- β Implement proper authentication (JWT, OAuth)
- β Enforce authorization checks on every endpoint
- β Validate and sanitize all inputs
- β Use parameterized queries (prevent SQL injection)
- β Implement rate limiting
- β Set up CORS properly
- β Use security headers (Helmet)
- β Implement CSRF protection
- β Hash passwords with bcrypt (salt rounds β₯ 12)
- β Never expose sensitive data in errors
- β Log security events
- β Keep dependencies updated
- β Use environment variables for secrets
- β Implement request timeouts
- β Regular security audits
β οΈ Common Security Mistakes:
- β Trusting client-side validation only
- β Using weak JWT secrets
- β Not implementing rate limiting
- β Exposing stack traces in production
- β Using default credentials
- β Not validating file uploads
- β Storing passwords in plain text
- β Not checking object-level permissions
- β Allowing unlimited request sizes
- β Not implementing proper CORS
Conclusion
API security requires defense in depth. Implement authentication, authorization, input validation, rate limiting, and proper error handling. Stay updated on OWASP guidelines, audit dependencies regularly, and always assume inputs are malicious. Security is not a featureβit's a requirement.
π‘ Pro Tip: Use security scanning tools in your CI/CD pipeline. Tools like Snyk, npm audit, and OWASP ZAP can catch vulnerabilities before they reach production. Automate security testing alongside unit and integration tests.