API Security Fundamentals
APIs are often the most exposed part of your application. A secure API requires multiple layers of protection.
API Authentication
1. API Keys
// Simple API key authentication
function apiKeyAuth(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Validate key (use timing-safe comparison)
const validKey = await db.apiKey.findUnique({
where: { key: hashApiKey(apiKey) },
include: { user: true },
});
if (!validKey || validKey.revoked) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.user = validKey.user;
req.apiKey = validKey;
next();
}
// Generate secure API keys
function generateApiKey() {
return crypto.randomBytes(32).toString('hex');
}
// Store hashed keys
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
2. OAuth 2.0 / JWT
// JWT authentication middleware
async function jwtAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Bearer token required' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
// Check token is not revoked
const isRevoked = await isTokenRevoked(payload.jti);
if (isRevoked) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = payload;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
Rate Limiting
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
// Different limits for different endpoints
const publicLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
message: { error: 'Too many requests' },
});
const authLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Too many authentication attempts' },
});
// Per-user rate limiting
const userLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 60 * 1000,
max: 60,
keyGenerator: (req) => req.user?.id || req.ip,
message: { error: 'Rate limit exceeded' },
});
app.use('/api', publicLimiter);
app.use('/api/auth', authLimiter);
app.use('/api/protected', jwtAuth, userLimiter);
Input Validation
import { z } from 'zod';
// Define schemas
const createUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional(),
});
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['asc', 'desc']).default('desc'),
});
// Validation middleware
function validate(schema) {
return (req, res, next) => {
try {
req.validated = schema.parse({
...req.body,
...req.query,
...req.params,
});
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors,
});
}
next(error);
}
};
}
app.post('/api/users', validate(createUserSchema), createUser);
app.get('/api/users', validate(paginationSchema), listUsers);
Secure Error Handling
// Custom error classes
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
// Error handling middleware
function errorHandler(err, req, res, next) {
// Log error internally
logger.error({
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: req.user?.id,
});
// Don't leak internal errors to client
if (err.isOperational) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
}
// Generic error for unexpected errors
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR',
});
}
app.use(errorHandler);
CORS Configuration
const cors = require('cors');
// Strict CORS configuration
const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
];
// Allow requests with no origin (mobile apps, curl)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
maxAge: 86400, // 24 hours
};
app.use(cors(corsOptions));
Request Sanitization
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
// Prevent NoSQL injection
app.use(mongoSanitize());
// Sanitize user input
app.use(xss());
// Custom sanitization
function sanitizeInput(obj) {
if (typeof obj === 'string') {
return obj.replace(/[<>]/g, '');
}
if (typeof obj === 'object' && obj !== null) {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key, sanitizeInput(value)])
);
}
return obj;
}
app.use((req, res, next) => {
req.body = sanitizeInput(req.body);
req.query = sanitizeInput(req.query);
next();
});
API Security Checklist
- Use HTTPS for all endpoints
- Implement proper authentication (API keys, JWT, OAuth)
- Apply rate limiting per-user and per-endpoint
- Validate all input with strict schemas
- Return generic errors (don't leak internals)
- Configure CORS properly
- Sanitize input to prevent injection
- Log all API access for audit
- Use API versioning
- Implement request signing for sensitive operations