-
Notifications
You must be signed in to change notification settings - Fork 4
API Security Best Practices
APIs (Application Programming Interfaces) are the connective tissue of modern applications. Securing APIs is critical as they often expose sensitive data and functionality. This guide covers comprehensive API security best practices to protect your services.
- Authentication flaws - Weak or missing authentication mechanisms
- Authorization issues - Improper access controls
- Input validation gaps - Accepting and processing malicious input
- Excessive data exposure - Returning more data than necessary
- Resource constraints lacking - Allowing excessive usage or abuse
- Security misconfigurations - Insecure default settings
- Injection flaws - Command or query injection vulnerabilities
- Improper assets management - Unpatched or deprecated API versions
Method | Description | Best For |
---|---|---|
API Keys | Simple secret tokens | Low-risk internal APIs, developer access |
Bearer Tokens | Tokens passed in headers | Most API scenarios, especially JWTs |
OAuth 2.0 | Token delegation framework | Complex permissions, third-party integrations |
mTLS | Mutual certificate authentication | High-security environments |
// Node.js/Express example with JWT
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
// Middleware to authenticate JWT
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Access token required' });
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid or expired token' });
req.user = user;
next();
});
}
// Protected route
app.get('/api/protected-resource', authenticateToken, (req, res) => {
res.json({ data: 'This is protected data', user: req.user });
});
// Token issuance (login)
app.post('/api/login', (req, res) => {
// Verify credentials (simplified)
const user = authenticateUser(req.body.username, req.body.password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create tokens
const accessToken = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ id: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token securely (e.g., database)
saveRefreshToken(user.id, refreshToken);
res.json({
accessToken,
refreshToken,
expiresIn: 900 // 15 minutes in seconds
});
});
// Token refresh endpoint
app.post('/api/refresh-token', (req, res) => {
const refreshToken = req.body.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
// Verify token is valid and in our database
if (!isValidRefreshToken(refreshToken)) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid token' });
// Get full user data for new access token
const userData = getUserData(user.id);
const accessToken = jwt.sign(
{ id: userData.id, username: userData.username, role: userData.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({
accessToken,
expiresIn: 900 // 15 minutes in seconds
});
});
});
// Middleware for role-based authorization
function checkRole(roles) {
return (req, res, next) => {
// Get user from JWT authentication middleware
const user = req.user;
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Apply role checks to routes
app.get('/api/users',
authenticateToken,
checkRole(['admin']),
(req, res) => {
// Only admins can access this endpoint
res.json({ users: getAllUsers() });
}
);
app.get('/api/products',
authenticateToken,
checkRole(['admin', 'manager', 'viewer']),
(req, res) => {
// Multiple roles can access this endpoint
res.json({ products: getProducts() });
}
);
// Permission-based authorization (more flexible than roles)
function checkPermission(permission) {
return async (req, res, next) => {
const user = req.user;
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
// Get user permissions from database
const userPermissions = await getUserPermissions(user.id);
if (!userPermissions.includes(permission)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Apply permission checks
app.post('/api/articles',
authenticateToken,
checkPermission('articles:create'),
(req, res) => {
// Create article logic
}
);
app.delete('/api/articles/:id',
authenticateToken,
checkPermission('articles:delete'),
(req, res) => {
// Delete article logic
}
);
Using Express validator for Node.js:
const { body, param, query, validationResult } = require('express-validator');
app.post('/api/users',
// Validate request body
[
body('username')
.isLength({ min: 3, max: 50 }).withMessage('Username must be 3-50 characters')
.isAlphanumeric().withMessage('Username must contain only letters and numbers'),
body('email')
.isEmail().withMessage('Must provide a valid email')
.normalizeEmail(),
body('role')
.isIn(['admin', 'user', 'manager']).withMessage('Invalid role specified')
.optional(),
body('settings.*').optional() // Allow any settings object structure
],
(req, res) => {
// Check for validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process valid input
createUser(req.body);
res.status(201).json({ message: 'User created successfully' });
}
);
app.get('/api/products',
// Validate query parameters
[
query('page')
.optional()
.isInt({ min: 1 }).withMessage('Page must be a positive integer')
.toInt(),
query('limit')
.optional()
.isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1-100')
.toInt(),
query('sort')
.optional()
.isIn(['name', 'price', 'date']).withMessage('Invalid sort field')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Use validated and sanitized query params
const { page = 1, limit = 20, sort = 'date' } = req.query;
const products = getProducts(page, limit, sort);
res.json({ products });
}
);
app.get('/api/products/:id',
// Validate URL parameters
param('id').isInt().withMessage('Product ID must be an integer'),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const product = getProductById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json({ product });
}
);
// Sanitize and filter sensitive data
app.get('/api/users/:id', authenticateToken, async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Filter out sensitive data based on requesting user's role
const isAdmin = req.user.role === 'admin';
const isSameUser = req.user.id === user.id;
// Create sanitized response
const sanitizedUser = {
id: user.id,
username: user.username,
email: isSameUser || isAdmin ? user.email : undefined,
role: isAdmin ? user.role : undefined,
createdAt: user.createdAt,
// Only include personal details if admin or same user
...(isSameUser || isAdmin ? {
personalDetails: user.personalDetails
} : {})
};
res.json({ user: sanitizedUser });
});
Using Express Rate Limit:
const rateLimit = require('express-rate-limit');
// Global rate limiter - applies to all endpoints
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true, // Return rate limit info in the RateLimit-*
headers
legacyHeaders: false, // Disable the X-RateLimit-*
headers
message: {
error: 'Too many requests, please try again later.'
}
});
// Apply global rate limiting
app.use(globalLimiter);
// Create specific limiters for sensitive endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 failed attempts per hour
standardHeaders: true,
skipSuccessfulRequests: true, // Only count failed requests
message: {
error: 'Too many failed login attempts, please try again later.'
}
});
// Apply to login endpoint
app.post('/api/login', authLimiter, loginHandler);
// Create tier-based rate limits
const freeTierLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: 100, // 100 requests per hour
keyGenerator: (req) => req.user.id, // Rate limit by user ID, not IP
standardHeaders: true,
message: {
error: 'Rate limit exceeded for free tier.'
}
});
const premiumTierLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: 1000, // 1000 requests per hour
keyGenerator: (req) => req.user.id,
standardHeaders: true,
message: {
error: 'Rate limit exceeded for premium tier.'
}
});
// Dynamic rate limiting based on user tier
app.use('/api/data', authenticateToken, (req, res, next) => {
if (req.user.tier === 'premium') {
premiumTierLimiter(req, res, next);
} else {
freeTierLimiter(req, res, next);
}
});
// Limit JSON body size
app.use(express.json({ limit: '10kb' }));
// Limit URL-encoded data
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Custom file upload limits per route
const multer = require('multer');
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
}
});
app.post('/api/upload/avatar',
authenticateToken,
upload.single('avatar'),
(req, res) => {
// Handle avatar upload
}
);
app.post('/api/upload/document',
authenticateToken,
upload.single('document'),
(req, res) => {
// Handle document upload
}
);
const helmet = require('helmet');
// Apply security headers to all routes
app.use(helmet());
// Configure specific headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "trusted-scripts.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: []
}
},
crossOriginResourcePolicy: { policy: "same-site" },
crossOriginOpenerPolicy: { policy: "same-origin" }
}));
// Add custom security headers
app.use((req, res, next) => {
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
const cors = require('cors');
// Basic CORS setup
const corsOptions = {
origin: 'https://yourapplication.com', // Restrict to your domain
methods: ['GET', 'POST', 'PUT', 'DELETE'], // Allowed methods
allowedHeaders: ['Content-Type', 'Authorization'], // Allowed headers
exposedHeaders: ['Content-Range', 'X-Total-Count'], // Expose these headers
credentials: true, // Allow cookies
maxAge: 86400 // Cache preflight requests for 24 hours
};
app.use(cors(corsOptions));
// Dynamic CORS based on environment
if (process.env.NODE_ENV === 'development') {
app.use(cors({
origin: '*', // Allow any origin in development
credentials: true
}));
} else {
app.use(cors({
origin: [
'https://production-app.com',
'https://admin.production-app.com'
],
credentials: true
}));
}
// Route-specific CORS
app.options('/api/special-endpoint', cors({
origin: 'https://partner-site.com',
methods: ['POST'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Special-Header']
}));
app.post('/api/special-endpoint',
cors({
origin: 'https://partner-site.com',
methods: ['POST'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Special-Header']
}),
(req, res) => {
// Handle special endpoint
}
);
const winston = require('winston');
const expressWinston = require('express-winston');
// Create sanitized transport
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: