API Security Essentials: CORS, API Keys & Rate Limiting
Building secure APIs is critical in today’s interconnected landscape. According to recent security reports, over 80% of web traffic is API-based, and API-related breaches increased by 200% in 2024. Three fundamental mechanisms form the backbone of modern API security: CORS (Cross-Origin Resource Sharing) controls which applications can access your API, API keys authenticate requests, and rate limiting prevents abuse and resource exhaustion.
Why This Matters:
- APIs are often the primary attack vector for data breaches
- Misconfigured CORS policies can expose sensitive data
- Missing rate limiting can lead to DDoS attacks and server costs
- Weak authentication mechanisms enable unauthorized access
What You’ll Learn:
- How CORS works at the browser level and common misconfigurations
- Implementing secure API key authentication with hashing
- Building production-ready rate limiting with Redis
- Combining multiple security layers for defense-in-depth
- Advanced patterns like OAuth2, JWT, and mTLS
- Testing, monitoring, and troubleshooting security implementations
This comprehensive guide covers practical implementation with real-world code examples, explaining the “why” behind each approach and showing how to avoid common pitfalls.
Table of Contents
- Why API Security Matters
- CORS: Controlling Cross-Origin Access
- API Keys: Authenticating Requests
- Rate Limiting: Preventing Abuse
- Advanced Authentication Methods
- Security Headers and Best Practices
- Testing Your API Security
- Monitoring and Incident Response
- Common Security Pitfalls
- Putting It All Together
- Production Deployment Checklist
- Conclusion
Why API Security Matters
When you expose an API publicly, you’re opening a door to your application. Without proper security, this door can be exploited:
- Unauthorized access: Anyone could read or modify your data
- Resource exhaustion: Attackers could overwhelm your servers
- Data theft: Sensitive information could be stolen
- Malicious clients: Bad actors could impersonate legitimate users
CORS, API keys, and rate limiting address these threats at different layers. Understanding how they workโand how to misconfigure themโis essential for any API developer.
CORS: Controlling Cross-Origin Access
What Is CORS?
CORS is a browser security mechanism that controls which external websites can access your API. Without CORS, a malicious website couldn’t steal data from your APIโthe browser would block the request.
The Same-Origin Policy
Browsers enforce the same-origin policy: a web page can only make requests to the same origin (protocol, domain, and port).
Page origin: https://app.example.com:443
API origin: https://api.example.com:443
Result: โ BLOCKED (different domain)
Page origin: https://example.com:443
API origin: https://example.com:443
Result: โ
ALLOWED (same origin)
Page origin: https://example.com:443
API origin: https://example.com:8080
Result: โ BLOCKED (different port)
This policy prevents websites from accessing sensitive data without permission. But legitimate cross-origin requests are common:
- Your frontend (frontend.example.com) needs to call your API (api.example.com)
- Third-party integrations need to access your API
- Mobile apps need to fetch data
CORS provides a safe way to allow these requests while blocking malicious ones.
How CORS Works: Preflight Requests
For non-simple requests (anything with custom headers, PUT/DELETE methods, or JSON payloads), the browser sends a preflight request before the actual request:
1. Browser sends OPTIONS request:
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type
2. Server responds with CORS headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
3. Browser checks: Is the origin allowed? Are these methods/headers allowed?
4. If yes, browser sends the actual request:
POST /api/users HTTP/1.1
Origin: https://app.example.com
Content-Type: application/json
{ "name": "John" }
Implementing CORS Correctly
// Express.js CORS implementation
const express = require('express');
const cors = require('cors');
const app = express();
// โ WRONG - Allow all origins (too permissive)
app.use(cors());
// โ
CORRECT - Whitelist specific origins
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
'https://mobile.example.com'
];
app.use(cors({
origin: (origin, callback) => {
if (allowedOrigins.includes(origin) || !origin) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 3600 // Cache preflight for 1 hour
}));
// Handle preflight requests explicitly (optional)
app.options('/api/*', cors());
app.post('/api/users', (req, res) => {
res.json({ message: 'User created' });
});
CORS Headers Explained
| Header | Purpose | Example |
|---|---|---|
Access-Control-Allow-Origin |
Which origins can access the API | https://app.example.com or * |
Access-Control-Allow-Methods |
Which HTTP methods are allowed | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers |
Which request headers are allowed | Content-Type, Authorization |
Access-Control-Allow-Credentials |
Whether cookies/auth headers are allowed | true or false |
Access-Control-Max-Age |
How long to cache preflight results | 3600 (seconds) |
Common CORS Misconfigurations
// โ WRONG - Too permissive
app.use(cors({
origin: '*', // Allow ANY origin
credentials: true // Also allow credentials? No, this will fail
}));
// โ WRONG - Regex that's too broad
app.use(cors({
origin: /.*example.com/ // Matches attacker.example.com.attacker.com
}));
// โ WRONG - No credentials check
app.use(cors({
origin: '*',
credentials: true // Contradiction! Browsers will reject this
}));
// โ
CORRECT - Explicit whitelist
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',');
app.use(cors({
origin: allowedOrigins,
credentials: true
}));
API Keys: Authenticating Requests
What Are API Keys?
API keys are credentials that identify your application. They’re different from passwordsโthey don’t prove you are who you claim, but rather that you’re authorized to use the API.
API keys serve three purposes:
- Identification: Which application is making the request?
- Authorization: Is this application allowed to use the API?
- Rate limiting: How many requests has this key made today?
API Keys vs Other Authentication Methods
| Method | Use Case | Security |
|---|---|---|
| API Key | Public APIs, third-party integrations | Low (single secret) |
| OAuth2 | User on behalf of user, delegated access | High (token-based, scoped) |
| mTLS | Service-to-service communication | Very High (certificate-based) |
| JWT | Stateless, user authentication | Medium (depends on implementation) |
API keys are simple but less secure than OAuth2. Use them for:
- Third-party integrations
- Public APIs where authentication is “nice to have”
- Simple service-to-service communication
Use OAuth2 for sensitive operations requiring user consent.
Where to Place API Keys
โ Query Parameters (Insecure)
GET /api/users?api_key=sk_live_abc123
Problems:
- Keys appear in server logs
- Keys appear in browser history
- Keys appear in URL bar
- Easy for attackers to intercept
โ HTTP Headers (Secure)
GET /api/users HTTP/1.1
Authorization: Bearer sk_live_abc123
// OR
X-API-Key: sk_live_abc123
Benefits:
- Not logged in URLs
- Encrypted with HTTPS
- Standard HTTP mechanism
- Can be restricted by the browser
Implementing API Key Authentication
// Middleware to verify API key
function verifyApiKey(req, res, next) {
// Get key from Authorization header
const authHeader = req.headers.authorization;
const apiKey = authHeader && authHeader.split(' ')[1];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Verify key against database
ApiKey.findOne({ key: apiKey, revoked: false }, async (err, keyDoc) => {
if (err || !keyDoc) {
return res.status(401).json({ error: 'Invalid API key' });
}
// Check if key has expired
if (keyDoc.expiresAt && keyDoc.expiresAt < new Date()) {
return res.status(401).json({ error: 'API key expired' });
}
// Attach key info to request
req.apiKey = keyDoc;
req.clientId = keyDoc.clientId;
next();
});
}
// Apply middleware to protected routes
app.get('/api/users', verifyApiKey, (req, res) => {
res.json({ users: [], clientId: req.clientId });
});
// Key generation endpoint
app.post('/api/keys/generate', authenticate, async (req, res) => {
const apiKey = crypto.randomBytes(32).toString('hex');
const keyPrefix = 'sk_live_'; // Public prefix
// Store hashed key (never store plaintext!)
const hashedKey = crypto
.createHash('sha256')
.update(apiKey)
.digest('hex');
await ApiKey.create({
key: hashedKey,
clientId: req.user.id,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
createdAt: new Date()
});
// Return key only once (unhashed)
res.json({
message: 'API key created',
apiKey: keyPrefix + apiKey,
warning: 'Save this key securely. You won\'t see it again.'
});
});
API Key Best Practices
1. Rotate Keys Regularly
// Implement key rotation schedule
app.post('/api/keys/rotate', authenticate, async (req, res) => {
const oldKey = req.body.keyId;
// Create new key
const newApiKey = crypto.randomBytes(32).toString('hex');
const newKeyHash = crypto.createHash('sha256').update(newApiKey).digest('hex');
// Mark old key as deprecated but don't delete (allow grace period)
await ApiKey.findByIdAndUpdate(oldKey, {
deprecated: true,
rotatedAt: new Date()
});
// Create new key
const newKey = await ApiKey.create({
key: newKeyHash,
clientId: req.user.id,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
});
res.json({
message: 'Key rotated',
newApiKey: 'sk_live_' + newApiKey,
oldKeyDeprecationPeriod: '30 days'
});
});
2. Never Store Keys in Source Code
// โ WRONG - Key in code
const API_KEY = 'sk_live_abc123xyz';
// โ
RIGHT - Key in environment variable
const API_KEY = process.env.API_KEY;
3. Use Hashed Keys in Database
// โ WRONG - Store plaintext
await ApiKey.create({ key: userProvidedKey });
// โ
RIGHT - Store hash
const hashedKey = crypto.createHash('sha256').update(userProvidedKey).digest('hex');
await ApiKey.create({ key: hashedKey });
4. Implement Key Scopes
// API key with limited permissions
const apiKey = {
id: 'key_123',
clientId: 'client_456',
scopes: ['read:users', 'write:orders'], // Limited permissions
expiresAt: new Date('2025-12-31'),
rateLimit: {
requests: 1000,
window: '1 hour'
}
};
// Enforce scopes
app.delete('/api/users/:id', verifyApiKey, (req, res) => {
if (!req.apiKey.scopes.includes('delete:users')) {
return res.status(403).json({ error: 'Permission denied' });
}
// Delete user...
});
Rate Limiting: Preventing Abuse
Why Rate Limiting Matters
Without rate limiting, a single malicious actor can:
- Overwhelm your servers with requests
- Brute-force passwords (try many combinations)
- Scrape your entire database
- Prevent legitimate users from accessing the API
Rate limiting throttles requests based on client, preventing abuse while allowing legitimate use.
Rate Limiting Strategies
Fixed Window Counter
Time: 00:00 - 01:00
Requests allowed: 1000
Current count: 950
User can make 50 more requests
Window resets at 01:00
Pros: Simple, easy to understand Cons: Requests can burst at window boundaries
Sliding Window Log
Last 60 minutes of requests: [00:05, 00:12, 00:45, 00:58, ...]
Count: 45
User can make 955 more requests
Always looks back 60 minutes
Pros: Accurate, prevents bursting Cons: Memory intensive (must store all request timestamps)
Token Bucket (Recommended)
Bucket capacity: 1000 tokens
Current tokens: 750
Refill rate: 10 tokens/second
Each request costs 1 token
Pros: Allows controlled bursting, smooth rate limiting Cons: Slightly more complex to implement
Implementing Rate Limiting
const Redis = require('redis');
const redis = Redis.createClient();
// Token bucket rate limiter
async function rateLimitTokenBucket(req, res, next) {
const clientId = req.apiKey?.clientId || req.ip;
const key = `rate_limit:${clientId}`;
const capacity = 1000; // Max tokens
const refillRate = 10; // Tokens per second
const refillInterval = 1000; // Milliseconds
try {
// Get current bucket state
const bucket = await redis.hGetAll(key);
const now = Date.now();
let tokens = bucket.tokens ? parseFloat(bucket.tokens) : capacity;
const lastRefillTime = bucket.lastRefillTime ? parseInt(bucket.lastRefillTime) : now;
// Refill tokens based on time elapsed
const timePassed = now - lastRefillTime;
const tokensToAdd = (timePassed / refillInterval) * refillRate;
tokens = Math.min(capacity, tokens + tokensToAdd);
// Check if request can proceed
if (tokens < 1) {
res.status(429).set({
'Retry-After': Math.ceil((1 - tokens) / refillRate),
'X-RateLimit-Limit': capacity,
'X-RateLimit-Remaining': 0,
'X-RateLimit-Reset': new Date(now + ((1 - tokens) / refillRate * 1000)).toISOString()
}).json({
error: 'Too many requests',
retryAfter: Math.ceil((1 - tokens) / refillRate)
});
return;
}
// Consume token
tokens -= 1;
// Update bucket in Redis
await redis.hSet(key, {
tokens: tokens.toString(),
lastRefillTime: now.toString()
});
await redis.expire(key, 3600); // Expire key after 1 hour
// Add rate limit headers
res.set({
'X-RateLimit-Limit': capacity,
'X-RateLimit-Remaining': Math.floor(tokens),
'X-RateLimit-Reset': new Date(now + ((capacity - tokens) / refillRate * 1000)).toISOString()
});
next();
} catch (error) {
console.error('Rate limit error:', error);
// On error, allow request (fail open)
next();
}
}
// Apply rate limiting
app.use(verifyApiKey);
app.use(rateLimitTokenBucket);
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});
Rate Limit Headers
The HTTP standard defines headers to communicate rate limit status:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 750
X-RateLimit-Reset: 2025-12-12T14:30:00Z
// When limit exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2025-12-12T14:30:00Z
Clients can use these headers to adjust their behavior before hitting the limit.
Tiered Rate Limiting
// Different limits for different clients
const rateLimits = {
free: { requests: 100, window: '1 hour' },
pro: { requests: 10000, window: '1 hour' },
enterprise: { requests: Infinity } // Unlimited
};
async function getTierRateLimit(clientId) {
const tier = await Client.findById(clientId).select('tier');
return rateLimits[tier.tier] || rateLimits.free;
}
app.use(async (req, res, next) => {
const limit = await getTierRateLimit(req.clientId);
req.rateLimit = limit;
next();
});
Advanced Authentication Methods
OAuth 2.0: Delegated Authorization
OAuth 2.0 is the industry standard for authorization, allowing users to grant third-party applications limited access to their resources without sharing passwords.
OAuth 2.0 Flow (Authorization Code)
1. User clicks "Login with Google" on your app
2. Redirected to authorization server (Google)
3. User grants permission
4. Authorization server redirects back with authorization code
5. Your app exchanges code for access token
6. App uses access token to access user's resources
Implementation Example
const express = require('express');
const axios = require('axios');
const app = express();
// OAuth configuration
const oauth = {
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
redirectUri: 'https://yourapp.com/callback',
authorizationUrl: 'https://oauth-provider.com/authorize',
tokenUrl: 'https://oauth-provider.com/token',
scopes: ['read:profile', 'write:data']
};
// Step 1: Redirect to authorization server
app.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex'); // CSRF protection
req.session.oauthState = state;
const authUrl = `${oauth.authorizationUrl}?` +
`client_id=${oauth.clientId}&` +
`redirect_uri=${encodeURIComponent(oauth.redirectUri)}&` +
`response_type=code&` +
`scope=${oauth.scopes.join(' ')}&` +
`state=${state}`;
res.redirect(authUrl);
});
// Step 2: Handle callback with authorization code
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state (CSRF protection)
if (state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state parameter' });
}
try {
// Exchange authorization code for access token
const tokenResponse = await axios.post(oauth.tokenUrl, {
grant_type: 'authorization_code',
code,
redirect_uri: oauth.redirectUri,
client_id: oauth.clientId,
client_secret: oauth.clientSecret
});
const { access_token, refresh_token, expires_in } = tokenResponse.data;
// Store tokens securely
req.session.accessToken = access_token;
req.session.refreshToken = refresh_token;
req.session.tokenExpiry = Date.now() + (expires_in * 1000);
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth error:', error.response?.data || error.message);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Middleware to verify access token
async function verifyOAuthToken(req, res, next) {
const token = req.session.accessToken;
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Check if token expired
if (Date.now() >= req.session.tokenExpiry) {
// Attempt to refresh token
try {
const refreshResponse = await axios.post(oauth.tokenUrl, {
grant_type: 'refresh_token',
refresh_token: req.session.refreshToken,
client_id: oauth.clientId,
client_secret: oauth.clientSecret
});
req.session.accessToken = refreshResponse.data.access_token;
req.session.tokenExpiry = Date.now() + (refreshResponse.data.expires_in * 1000);
} catch (error) {
return res.status(401).json({ error: 'Token refresh failed' });
}
}
// Verify token with authorization server (optional but recommended)
try {
const verifyResponse = await axios.get('https://oauth-provider.com/userinfo', {
headers: { Authorization: `Bearer ${token}` }
});
req.user = verifyResponse.data;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Protected endpoint
app.get('/api/profile', verifyOAuthToken, (req, res) => {
res.json({ user: req.user });
});
JWT (JSON Web Tokens): Stateless Authentication
JWT enables stateless authenticationโno need to store session data on the server.
JWT Structure
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Part 1: Header (algorithm & token type)
Part 2: Payload (claims/data)
Part 3: Signature (verification)
Implementation
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// Secret key (store in environment variable)
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRY = '1h';
const REFRESH_TOKEN_EXPIRY = '7d';
// Login endpoint
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// Find user
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate access token
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role
},
JWT_SECRET,
{
expiresIn: JWT_EXPIRY,
issuer: 'yourapp.com',
audience: 'api.yourapp.com'
}
);
// Generate refresh token
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// Store refresh token in database
await RefreshToken.create({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({
accessToken,
refreshToken,
expiresIn: 3600
});
});
// Middleware to verify JWT
function verifyJWT(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'yourapp.com',
audience: 'api.yourapp.com'
});
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Refresh token endpoint
app.post('/api/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, JWT_SECRET);
// Check if token exists in database
const storedToken = await RefreshToken.findOne({
userId: decoded.sub,
token: refreshToken
});
if (!storedToken) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Generate new access token
const user = await User.findById(decoded.sub);
const newAccessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
);
res.json({ accessToken: newAccessToken });
} catch (error) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Protected route
app.get('/api/protected', verifyJWT, (req, res) => {
res.json({ message: 'Protected data', user: req.user });
});
// Logout (invalidate refresh token)
app.post('/api/logout', verifyJWT, async (req, res) => {
await RefreshToken.deleteMany({ userId: req.user.sub });
res.json({ message: 'Logged out successfully' });
});
JWT Best Practices
// โ
DO: Use strong secret keys
const JWT_SECRET = crypto.randomBytes(64).toString('hex');
// โ
DO: Set appropriate expiration times
const accessTokenExpiry = '15m'; // Short-lived
const refreshTokenExpiry = '7d'; // Long-lived
// โ
DO: Include necessary claims
const payload = {
sub: user.id, // Subject (user ID)
iat: Date.now(), // Issued at
exp: expiry, // Expiration
iss: 'yourapp.com', // Issuer
aud: 'api.yourapp.com' // Audience
};
// โ DON'T: Store sensitive data in JWT
const badPayload = {
password: 'secret', // Never!
creditCard: '1234...', // Never!
ssn: '123-45-6789' // Never!
};
// โ
DO: Validate all claims
jwt.verify(token, secret, {
algorithms: ['HS256'], // Specify allowed algorithms
issuer: 'yourapp.com',
audience: 'api.yourapp.com'
});
mTLS: Mutual TLS Authentication
For service-to-service communication, mTLS provides strong authentication using certificates.
const https = require('https');
const fs = require('fs');
// Server configuration with mTLS
const options = {
key: fs.readFileSync('server-key.pem'),
cert: fs.readFileSync('server-cert.pem'),
ca: fs.readFileSync('ca-cert.pem'),
requestCert: true, // Request client certificate
rejectUnauthorized: true // Reject invalid certificates
};
https.createServer(options, (req, res) => {
// Client certificate is verified by TLS layer
const clientCert = req.connection.getPeerCertificate();
if (req.client.authorized) {
console.log('Client authenticated:', clientCert.subject.CN);
res.writeHead(200);
res.end('Success');
} else {
console.log('Client not authorized:', req.connection.authorizationError);
res.writeHead(401);
res.end('Unauthorized');
}
}).listen(8443);
// Client making mTLS request
const clientOptions = {
hostname: 'api.example.com',
port: 8443,
path: '/api/data',
method: 'GET',
key: fs.readFileSync('client-key.pem'),
cert: fs.readFileSync('client-cert.pem'),
ca: fs.readFileSync('ca-cert.pem')
};
const req = https.request(clientOptions, (res) => {
res.on('data', (d) => {
console.log(d.toString());
});
});
req.end();
When to Use Which Method
| Method | Use Case | Security Level | Complexity |
|---|---|---|---|
| API Keys | Public APIs, simple authentication | Low | Low |
| OAuth 2.0 | Third-party integrations, user authorization | High | Medium |
| JWT | Stateless auth, microservices | Medium-High | Medium |
| mTLS | Service-to-service, highly sensitive | Very High | High |
| Session Cookies | Traditional web apps | Medium | Low |
Security Headers and Best Practices
Essential Security Headers
const helmet = require('helmet');
// Apply security headers
app.use(helmet());
// Or configure individually
app.use((req, res, next) => {
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Enable XSS protection
res.setHeader('X-XSS-Protection', '1; mode=block');
// Strict Transport Security (HTTPS only)
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Content Security Policy
res.setHeader('Content-Security-Policy', "default-src 'self'");
// Referrer policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
next();
});
Input Validation and Sanitization
const { body, validationResult } = require('express-validator');
// Validate and sanitize inputs
app.post('/api/users',
[
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Invalid email'),
body('username')
.isLength({ min: 3, max: 20 })
.trim()
.escape()
.withMessage('Username must be 3-20 characters'),
body('age')
.optional()
.isInt({ min: 0, max: 120 })
.withMessage('Age must be between 0 and 120')
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process validated data
res.json({ message: 'User created' });
}
);
SQL Injection Prevention
// โ VULNERABLE to SQL injection
app.get('/api/users/:id', (req, res) => {
const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
db.query(query); // Don't do this!
});
// โ
SAFE: Use parameterized queries
app.get('/api/users/:id', (req, res) => {
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [req.params.id]);
});
// โ
SAFE: Use ORM
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
NoSQL Injection Prevention
// โ VULNERABLE to NoSQL injection
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
// Attacker could send: { "username": { "$ne": null }, "password": { "$ne": null } }
User.findOne({ username, password });
});
// โ
SAFE: Sanitize inputs
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize());
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
User.findOne({ username: String(username) });
});
Testing Your API Security
Unit Testing Security Middleware
const request = require('supertest');
const app = require('./app');
describe('API Security Tests', () => {
describe('CORS', () => {
it('should reject requests from unauthorized origins', async () => {
const res = await request(app)
.get('/api/users')
.set('Origin', 'https://malicious.com');
expect(res.status).toBe(403);
});
it('should allow requests from authorized origins', async () => {
const res = await request(app)
.get('/api/users')
.set('Origin', 'https://app.example.com');
expect(res.status).not.toBe(403);
expect(res.headers['access-control-allow-origin'])
.toBe('https://app.example.com');
});
});
describe('API Key Authentication', () => {
it('should reject requests without API key', async () => {
const res = await request(app)
.get('/api/users');
expect(res.status).toBe(401);
expect(res.body.error).toBe('API key required');
});
it('should reject requests with invalid API key', async () => {
const res = await request(app)
.get('/api/users')
.set('Authorization', 'Bearer invalid_key');
expect(res.status).toBe(401);
});
it('should accept requests with valid API key', async () => {
const validKey = 'sk_test_valid_key_123';
const res = await request(app)
.get('/api/users')
.set('Authorization', `Bearer ${validKey}`);
expect(res.status).toBe(200);
});
});
describe('Rate Limiting', () => {
it('should allow requests within rate limit', async () => {
for (let i = 0; i < 10; i++) {
const res = await request(app)
.get('/api/users')
.set('Authorization', 'Bearer test_key');
expect(res.status).toBe(200);
}
});
it('should block requests exceeding rate limit', async () => {
// Exhaust rate limit
for (let i = 0; i < 100; i++) {
await request(app)
.get('/api/users')
.set('Authorization', 'Bearer test_key');
}
// Next request should be rate limited
const res = await request(app)
.get('/api/users')
.set('Authorization', 'Bearer test_key');
expect(res.status).toBe(429);
expect(res.body.error).toContain('Rate limit');
expect(res.headers['retry-after']).toBeDefined();
});
it('should include rate limit headers', async () => {
const res = await request(app)
.get('/api/users')
.set('Authorization', 'Bearer test_key');
expect(res.headers['x-ratelimit-limit']).toBeDefined();
expect(res.headers['x-ratelimit-remaining']).toBeDefined();
expect(res.headers['x-ratelimit-reset']).toBeDefined();
});
});
});
Security Penetration Testing
# Install security testing tools
npm install --save-dev owasp-zap newman
# Test for common vulnerabilities
# SQL Injection
curl -X GET "https://api.example.com/users?id=1' OR '1'='1"
# XSS
curl -X POST "https://api.example.com/comments" \
-d '{"text":"<script>alert(1)</script>"}'
# CSRF
curl -X DELETE "https://api.example.com/users/123" \
--cookie "session=victim_session"
# Command Injection
curl -X GET "https://api.example.com/ping?host=example.com;ls"
Load Testing Rate Limits
// Using Artillery for load testing
// artillery.yml
config:
target: 'https://api.example.com'
phases:
- duration: 60
arrivalRate: 100 # 100 requests/second
scenarios:
- name: "Test rate limiting"
flow:
- get:
url: "/api/users"
headers:
Authorization: "Bearer test_key"
# Run load test
artillery run artillery.yml
Monitoring and Incident Response
Real-Time Monitoring
const winston = require('winston');
const prometheus = require('prom-client');
// Set up logging
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: 'security.log' })
]
});
// Prometheus metrics
const httpRequestDuration = new prometheus.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status']
});
const authFailures = new prometheus.Counter({
name: 'auth_failures_total',
help: 'Total number of authentication failures',
labelNames: ['reason']
});
const rateLimitHits = new prometheus.Counter({
name: 'rate_limit_hits_total',
help: 'Total number of rate limit hits',
labelNames: ['client_id']
});
// Monitoring middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
httpRequestDuration
.labels(req.method, req.route?.path || req.path, res.statusCode)
.observe(duration);
// Log security events
if (res.statusCode === 401) {
authFailures.inc({ reason: 'invalid_credentials' });
logger.warn('Authentication failure', {
ip: req.ip,
path: req.path,
userAgent: req.get('user-agent')
});
}
if (res.statusCode === 429) {
rateLimitHits.inc({ client_id: req.clientId });
logger.warn('Rate limit exceeded', {
clientId: req.clientId,
ip: req.ip
});
}
});
next();
});
// Expose metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', prometheus.register.contentType);
res.end(await prometheus.register.metrics());
});
Alerting System
const nodemailer = require('nodemailer');
const slack = require('slack-notify')(process.env.SLACK_WEBHOOK);
// Alert thresholds
const THRESHOLDS = {
authFailures: 100, // per minute
rateLimitHits: 1000, // per minute
errorRate: 0.05 // 5%
};
// Check thresholds periodically
setInterval(async () => {
const metrics = await getSecurityMetrics();
if (metrics.authFailures > THRESHOLDS.authFailures) {
sendAlert({
severity: 'HIGH',
title: 'High Authentication Failure Rate',
message: `${metrics.authFailures} auth failures in last minute`,
details: metrics.topFailingIPs
});
}
if (metrics.rateLimitHits > THRESHOLDS.rateLimitHits) {
sendAlert({
severity: 'MEDIUM',
title: 'High Rate Limit Hit Rate',
message: `${metrics.rateLimitHits} rate limit hits in last minute`
});
}
}, 60000); // Check every minute
function sendAlert(alert) {
// Log alert
logger.error('Security alert', alert);
// Send to Slack
slack.alert({
text: `๐จ ${alert.title}`,
fields: {
Severity: alert.severity,
Message: alert.message,
Time: new Date().toISOString()
}
});
// Send email for high severity
if (alert.severity === 'HIGH') {
sendEmailAlert(alert);
}
}
Incident Response Playbook
1. API Key Compromise Detection
// Detect suspicious API key usage
app.use(async (req, res, next) => {
if (!req.apiKey) return next();
const usage = await getKeyUsagePattern(req.apiKey.id);
// Anomaly detection
if (usage.requestsPerMinute > usage.historicalAverage * 5) {
logger.warn('Anomalous API key usage detected', {
keyId: req.apiKey.id,
current: usage.requestsPerMinute,
average: usage.historicalAverage
});
// Auto-revoke key if very suspicious
if (usage.requestsPerMinute > usage.historicalAverage * 10) {
await revokeApiKey(req.apiKey.id);
logger.error('API key auto-revoked', { keyId: req.apiKey.id });
return res.status(401).json({ error: 'API key revoked' });
}
}
next();
});
2. DDoS Mitigation
// Emergency rate limiting
function emergencyRateLimit(req, res, next) {
const ip = req.ip;
const key = `emergency:${ip}`;
redis.incr(key, (err, count) => {
if (err) return next();
redis.expire(key, 60); // 1 minute window
if (count > 10) { // Very restrictive
return res.status(429).json({ error: 'Service temporarily restricted' });
}
next();
});
}
// Activate during attack
if (process.env.UNDER_ATTACK === 'true') {
app.use(emergencyRateLimit);
}
Common Security Pitfalls
Pitfall 1: CORS Origin: * with Credentials
// โ WRONG - Browsers reject this combination
app.use(cors({
origin: '*',
credentials: true
}));
// โ
CORRECT - Explicit origins OR no credentials
app.use(cors({
origin: ['https://app.example.com'],
credentials: true
}));
Pitfall 2: Storing API Keys in Plaintext
// โ WRONG
await ApiKey.create({ key: apiKey }); // Store plaintext
// โ
CORRECT
const hash = crypto.createHash('sha256').update(apiKey).digest('hex');
await ApiKey.create({ key: hash }); // Store hash
Pitfall 3: Ignoring Rate Limit Circumvention
// โ WRONG - Rate limit by user ID only
app.use(rateLimit({ keyGenerator: (req) => req.user.id }));
// โ
CORRECT - Rate limit by IP and user ID
app.use(rateLimit({
keyGenerator: (req) => `${req.ip}:${req.user.id}`
}));
Pitfall 4: Sending API Keys in Responses
// โ WRONG - Logging keys
console.log('User API key:', userKey);
// โ
CORRECT - Never log sensitive data
console.log('API key generated for client:', clientId);
Putting It All Together
Here’s a complete example combining all three mechanisms:
const express = require('express');
const cors = require('cors');
const redis = require('redis');
const app = express();
const redisClient = redis.createClient();
// 1. CORS - Allow specific origins
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// 2. API Key Authentication
async function verifyApiKey(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const keyHash = crypto.createHash('sha256').update(token).digest('hex');
const apiKey = await ApiKey.findOne({ key: keyHash });
if (!apiKey) return res.status(401).json({ error: 'Invalid key' });
req.clientId = apiKey.clientId;
next();
}
// 3. Rate Limiting
async function rateLimit(req, res, next) {
const limit = await getTierRateLimit(req.clientId);
const bucket = await getRateLimitBucket(req.clientId);
if (bucket.tokens < 1) {
return res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: 60
});
}
await consumeToken(req.clientId);
res.set('X-RateLimit-Remaining', Math.floor(bucket.tokens - 1));
next();
}
app.use(verifyApiKey);
app.use(rateLimit);
// Protected endpoint
app.get('/api/data', (req, res) => {
res.json({ data: 'sensitive information' });
});
app.listen(3000);
Production Deployment Checklist
Pre-Deployment Security Audit
Configuration Review
- CORS origins explicitly whitelisted (no
*) - API keys stored hashed in database
- Rate limiting configured per tier
- HTTPS enabled (no HTTP endpoints)
- Environment variables used for secrets
- Security headers configured (helmet.js)
- Input validation on all endpoints
- SQL/NoSQL injection prevention implemented
- Authentication required on protected routes
- Authorization checks on all mutations
Infrastructure Security
- Firewall rules configured
- Database access restricted to API server
- Redis secured with authentication
- Load balancer configured
- DDoS protection enabled (Cloudflare/AWS Shield)
- Backup strategy implemented
- Monitoring and alerting set up
- Logging configured (no sensitive data logged)
- Certificate management automated (Let’s Encrypt)
- Security patches applied to all dependencies
Testing Completed
- Unit tests pass (>80% coverage)
- Integration tests pass
- Security penetration testing completed
- Load testing performed
- Rate limiting tested under load
- CORS tested from multiple origins
- API key rotation tested
- Failure scenarios tested
Deployment Best Practices
// Environment-specific configuration
const config = {
development: {
corsOrigins: ['http://localhost:3000'],
rateLimit: { requests: 1000000, window: '1h' },
logLevel: 'debug'
},
staging: {
corsOrigins: ['https://staging.example.com'],
rateLimit: { requests: 10000, window: '1h' },
logLevel: 'info'
},
production: {
corsOrigins: [
'https://app.example.com',
'https://www.example.com'
],
rateLimit: { requests: 1000, window: '1h' },
logLevel: 'warn',
requireHttps: true,
strictCors: true
}
};
const env = process.env.NODE_ENV || 'development';
module.exports = config[env];
Performance Optimization
// Cache API responses
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 600 });
app.get('/api/public/data', (req, res) => {
const cacheKey = 'public_data';
const cached = cache.get(cacheKey);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(cached);
}
const data = fetchData();
cache.set(cacheKey, data);
res.set('X-Cache', 'MISS');
res.json(data);
});
// Connection pooling for database
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
max: 20, // Maximum connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
// Compression
const compression = require('compression');
app.use(compression());
Graceful Shutdown
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, closing server gracefully...');
server.close(() => {
console.log('HTTP server closed');
// Close database connections
pool.end(() => {
console.log('Database pool closed');
// Close Redis connection
redis.quit(() => {
console.log('Redis connection closed');
process.exit(0);
});
});
});
// Force shutdown after 30 seconds
setTimeout(() => {
console.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 30000);
});
Monitoring Dashboard Setup
# Grafana dashboard configuration
apiVersion: 1
providers:
- name: 'Prometheus'
type: prometheus
url: http://prometheus:9090
dashboards:
- title: "API Security Metrics"
panels:
- title: "Authentication Failures"
type: graph
targets:
- expr: rate(auth_failures_total[5m])
- title: "Rate Limit Hits"
type: graph
targets:
- expr: rate(rate_limit_hits_total[5m])
- title: "Response Times"
type: graph
targets:
- expr: histogram_quantile(0.95, http_request_duration_seconds)
- title: "Error Rate"
type: graph
targets:
- expr: rate(http_requests_total{status=~"5.."}[5m])
Conclusion
API security is not optionalโit’s foundational. CORS, API keys, and rate limiting form the essential first layer of defense:
The Three Pillars:
- CORS: Controls browser-based access, preventing unauthorized domains from accessing your API
- API Keys: Authenticates clients, enabling tracking and per-client rate limiting
- Rate Limiting: Protects infrastructure from abuse, DDoS attacks, and resource exhaustion
Beyond the Basics:
- OAuth 2.0 for user authorization and third-party integrations
- JWT for stateless, scalable authentication
- mTLS for service-to-service communication
- Security headers for additional browser protection
- Monitoring for real-time threat detection
Key Takeaways:
โ
Never use Access-Control-Allow-Origin: * with credentials
โ
Always hash API keys before storing
โ
Implement token bucket rate limiting for smooth throttling
โ
Use HTTPS everywhere (no exceptions)
โ
Validate and sanitize all inputs
โ
Monitor authentication failures and rate limit hits
โ
Have an incident response plan ready
โ
Test security under load
โ
Keep dependencies updated
โ
Follow the principle of least privilege
Real-World Impact:
- APIs with proper rate limiting save $50,000-$500,000 annually in DDoS mitigation costs
- OAuth 2.0 reduces password-related breaches by 90%
- Properly configured CORS prevents 99% of cross-origin attacks
- API key rotation reduces breach impact by 75%
Security Is a Journey:
API security is not a one-time implementationโit requires continuous monitoring, testing, and improvement. Stay updated with:
- OWASP API Security Top 10 updates
- CVE databases for dependency vulnerabilities
- Security advisories from framework maintainers
- Industry best practices and emerging threats
Recommended Learning Path:
- Week 1-2: Implement CORS, API keys, and basic rate limiting
- Week 3-4: Add OAuth 2.0 and JWT authentication
- Week 5-6: Implement security headers and input validation
- Week 7-8: Set up monitoring, alerting, and logging
- Week 9-10: Perform security testing and penetration testing
- Week 11-12: Deploy to production with full security audit
Career Opportunities:
- API Security Engineer: $90k-$180k
- Security Architect: $130k-$250k
- DevSecOps Engineer: $100k-$200k
- Application Security Specialist: $95k-$190k
Your Next Steps:
- Audit your current API security implementation
- Implement missing security layers from this guide
- Set up monitoring and alerting
- Perform security testing
- Create incident response procedures
- Train your team on security best practices
Remember: Security is everyone’s responsibility. A chain is only as strong as its weakest link. By implementing defense-in-depth with CORS, API keys, rate limiting, and advanced authentication methods, you create a robust security posture that protects your users, your data, and your business.
Start securing your APIs todayโthe cost of prevention is always less than the cost of a breach.
Further Reading & Resources
- MDN: CORS Documentation
- OWASP: API Security Top 10
- RFC 7231: HTTP Semantics and Content
- Token Bucket Algorithm
- Stripe API Key Best Practices
- Express CORS Middleware
- Redis Rate Limiting Patterns
- NIST API Security Guidelines
Comments