Secure your APIs with comprehensive best practices. Learn API key management, OAuth scopes, rate limiting, input validation, and OWASP API Security Top 10.
API Security Best Practices
OWASP API Security Top 10
| # |
Risk |
Prevention |
| 1 |
Broken Object Level Auth |
Validate ownership per request |
| 2 |
Broken Authentication |
Strong auth + rate limits |
| 3 |
Broken Object Property Auth |
Allowlist response fields |
| 4 |
Unrestricted Resource Consumption |
Rate limits + request size limits |
| 5 |
Function Level Auth |
RBAC for every endpoint |
| 6 |
Unrestricted Access to Sensitive Flows |
Step-up auth for critical actions |
| 7 |
SSRF |
Validate/allowlist URLs |
| 8 |
Security Misconfiguration |
Disable debug, CORS, headers |
| 9 |
Improper Inventory Management |
API versioning + deprecation |
| 10 |
Unsafe API Consumption |
Validate third-party responses |
API Key Management
import crypto from 'crypto'
import { hash, compare } from 'bcryptjs'
class APIKeyManager {
// Generate API key with prefix for easy identification
generate(prefix = 'sk') {
const key = crypto.randomBytes(32).toString('base64url')
const apiKey = `${prefix}_${key}`
const keyId = crypto.randomUUID()
return { keyId, apiKey }
}
// Store hashed key (never store plaintext)
async store(keyId, apiKey, userId, scopes = []) {
const hashedKey = await hash(apiKey, 12)
await db.query(
'INSERT INTO api_keys (id, hashed_key, user_id, scopes, created_at) VALUES ($1, $2, $3, $4, NOW())',
[keyId, hashedKey, userId, JSON.stringify(scopes)]
)
}
async validate(apiKey) {
// Extract key prefix to find candidates
const [prefix] = apiKey.split('_')
const rows = await db.query('SELECT * FROM api_keys WHERE prefix = $1 AND revoked = false', [prefix])
for (const row of rows) {
if (await compare(apiKey, row.hashed_key)) {
await this.recordUsage(row.id)
return { userId: row.user_id, scopes: row.scopes }
}
}
return null
}
async rotate(oldKeyId, userId) {
const { keyId, apiKey } = this.generate()
await this.store(keyId, apiKey, userId)
await db.query('UPDATE api_keys SET revoked = true WHERE id = $1', [oldKeyId])
return { keyId, apiKey }
}
}
Broken Object Level Authorization (BOLA)
// VULNERABLE: No ownership check
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await db.findOne('orders', { id: req.params.id })
res.json(order) // Returns ANY order regardless of owner
})
// SECURE: Always validate ownership
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await db.findOne('orders', {
id: req.params.id,
user_id: req.user.id // Enforce ownership
})
if (!order) return res.status(404).json({ error: 'Not found' })
res.json(order)
})
// RBAC middleware
function requireScope(scope) {
return (req, res, next) => {
if (!req.user.scopes.includes(scope)) {
return res.status(403).json({ error: `Requires scope: ${scope}` })
}
next()
}
}
app.delete('/api/users/:id', authMiddleware, requireScope('users:delete'), handler)
Rate Limiting Strategies
import Bottleneck from 'bottleneck'
import { RateLimiterRedis } from 'rate-limiter-flexible'
import { createClient } from 'redis'
const redis = createClient()
// Per-user rate limiter
const userLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'rl_user',
points: 100, // requests
duration: 60, // per 60 seconds
blockDuration: 600, // block for 10 minutes
})
// Per-IP rate limiter
const ipLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'rl_ip',
points: 200,
duration: 60,
})
async function rateLimitMiddleware(req, res, next) {
const ip = req.ip
const userId = req.user?.id
try {
// Check IP limit
await ipLimiter.consume(ip)
// Check user limit if authenticated
if (userId) await userLimiter.consume(userId)
next()
} catch (rejInfo) {
const retryAfter = Math.round(rejInfo.msBeforeNext / 1000)
res.set('Retry-After', retryAfter)
res.status(429).json({
error: 'Too Many Requests',
retry_after: retryAfter,
})
}
}
Request Validation with Zod
import { z } from 'zod'
const CreateOrderSchema = z.object({
product_id: z.string().uuid(),
quantity: z.number().int().positive().max(100),
delivery_address: z.object({
street: z.string().max(200),
city: z.string().max(100),
country: z.string().length(2).toUpperCase(),
zip: z.string().regex(/^d{5}(-d{4})?$/),
}),
notes: z.string().max(500).optional(),
})
function validateBody(schema) {
return (req, res, next) => {
try {
req.validatedBody = schema.parse(req.body)
next()
} catch (err) {
res.status(400).json({
error: 'Validation failed',
details: err.errors.map(e => ({ path: e.path.join('.'), message: e.message })),
})
}
}
}
app.post('/api/orders', authMiddleware, validateBody(CreateOrderSchema), handler)
SSRF Prevention
import { URL } from 'url'
import ipRangeCheck from 'ip-range-check'
const BLOCKED_RANGES = [
'10.0.0.0/8', // Private networks
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8', // Loopback
'169.254.0.0/16', // Link-local
'::1/128', // IPv6 loopback
]
const ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com']
async function safeRequest(url) {
const parsed = new URL(url)
// Allowlist check
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
throw new Error(`Host not allowed: ${parsed.hostname}`)
}
// Resolve DNS and check IP
const { address } = await dns.promises.lookup(parsed.hostname)
if (ipRangeCheck(address, BLOCKED_RANGES)) {
throw new Error(`IP address blocked: ${address}`)
}
// Only allow HTTPS
if (parsed.protocol !== 'https:') {
throw new Error('Only HTTPS allowed')
}
return fetch(url, {
headers: { 'User-Agent': 'MyApp/1.0' },
redirect: 'manual', // Don't follow redirects automatically
})
}