API Security Best Practices
Essential security patterns for protecting your APIs from common attacks, with practical implementation examples for development teams.
Why API Security Deserves Special Attention
APIs are the front door to your data. Every mobile app, single-page application, third-party integration, and internal microservice communicates through APIs. A vulnerability in your API exposes not just one interface, but potentially every client that connects to it.
The OWASP API Security Top 10 lists the most common API vulnerabilities: broken object-level authorization, broken authentication, excessive data exposure, lack of rate limiting, and more. These are not theoretical risks. They are the attack patterns used in real breaches every week.
This guide covers practical security patterns that every development team should implement.
Authentication
Token-Based Authentication
Session cookies work for traditional web applications, but APIs serving mobile apps, SPAs, and third-party clients need token-based authentication. JSON Web Tokens (JWT) are the standard:
// Token structure
// Header: algorithm and token type
// Payload: claims (user data, permissions, expiration)
// Signature: cryptographic verification
const token = jwt.sign(
{
sub: user.id,
email: user.email,
roles: ['admin', 'editor'],
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (60 * 15) // 15 minutes
},
process.env.JWT_SECRET,
{ algorithm: 'HS256' }
);
Critical JWT practices:
- Short expiration: Access tokens should expire in 15 to 30 minutes
- Refresh tokens: Use longer-lived refresh tokens stored securely to obtain new access tokens
- Strong secrets: Use at least 256-bit keys. Never hardcode them.
- Algorithm validation: Always specify the expected algorithm to prevent algorithm confusion attacks
OAuth 2.0 for Third-Party Access
When external applications need access to your API, use OAuth 2.0 with the authorization code flow:
1. Client redirects user to authorization server
2. User authenticates and grants permissions
3. Authorization server returns authorization code
4. Client exchanges code for access token (server-to-server)
5. Client uses access token to call API
Never use the implicit flow for new applications. It exposes tokens in URLs and browser history.
API Keys for Service-to-Service
For internal service communication or trusted third-party integrations:
- Generate cryptographically random API keys (minimum 32 bytes)
- Hash stored API keys (never store them in plain text)
- Support key rotation without downtime
- Bind keys to specific IP ranges or services when possible
- Log all API key usage for audit purposes
Authorization
Object-Level Authorization
The most common API vulnerability: checking whether a user is authenticated but not whether they are authorized to access the specific resource.
// WRONG: Only checks authentication
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order); // Any authenticated user can see any order
});
// CORRECT: Checks object-level authorization
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
if (order.userId !== req.user.id && !req.user.roles.includes('admin')) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(order);
});
Every endpoint that accesses a specific resource must verify that the authenticated user has permission to access that particular resource.
Function-Level Authorization
Ensure that admin-only operations are actually restricted to admins:
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.some(role => req.user.roles.includes(role))) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.delete('/api/users/:id', authenticate, requireRole('admin'), async (req, res) => {
// Only admins reach this code
});
Field-Level Access Control
Not every user should see every field in a response:
function filterUserResponse(user, requestingUser) {
const publicFields = {
id: user.id,
name: user.name,
department: user.department
};
if (requestingUser.roles.includes('hr') || requestingUser.id === user.id) {
return {
...publicFields,
email: user.email,
phone: user.phone,
salary: requestingUser.roles.includes('hr') ? user.salary : undefined
};
}
return publicFields;
}
Input Validation
Validate Everything
Never trust client input. Validate type, format, length, and range for every parameter:
const { z } = require('zod');
const createOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(10000),
unitPrice: z.number().positive().max(999999999)
})).min(1).max(100),
deliveryDate: z.string().datetime(),
notes: z.string().max(500).optional()
});
app.post('/api/orders', authenticate, async (req, res) => {
const result = createOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues
});
}
// Process validated data
const order = await createOrder(result.data);
res.status(201).json(order);
});
SQL Injection Prevention
Always use parameterized queries. Never concatenate user input into SQL strings:
// DANGEROUS: SQL injection vulnerability
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// SAFE: Parameterized query
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [req.body.email]);
Rate Limiting
Protect Against Abuse
Without rate limiting, a single client can overwhelm your API with requests, whether through intentional attack or buggy code:
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
// Stricter limit for authentication endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 attempts per 15 minutes
message: { error: 'Too many login attempts' }
});
app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);
Graduated Rate Limiting
Apply different limits based on authentication status and endpoint sensitivity:
| Endpoint Type | Anonymous | Authenticated | Premium |
|---|---|---|---|
| Public read | 30/min | 100/min | 500/min |
| Authenticated read | N/A | 60/min | 300/min |
| Write operations | N/A | 20/min | 100/min |
| Authentication | 5/15min | N/A | N/A |
| Admin operations | N/A | 30/min | 30/min |
Response Security
Minimize Data Exposure
Return only the fields the client needs. Never return entire database records:
// BAD: Returns everything including sensitive fields
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // Includes password hash, internal notes, etc.
});
// GOOD: Returns only necessary fields
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id)
.select('id name email department createdAt');
res.json(user);
});
Security Headers
Set appropriate HTTP security headers on all API responses:
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
Error Handling
Never expose internal details in error responses:
// BAD: Leaks implementation details
res.status(500).json({
error: 'PostgreSQL error: relation "users" does not exist',
stack: error.stack
});
// GOOD: Generic message with correlation ID for debugging
const correlationId = crypto.randomUUID();
logger.error({ correlationId, error: error.message, stack: error.stack });
res.status(500).json({
error: 'An internal error occurred',
correlationId: correlationId
});
Logging and Monitoring
What to Log
Every API request should generate a log entry containing:
- Timestamp
- Request method and path
- Client IP address and user agent
- Authenticated user ID (if applicable)
- Response status code
- Response time
- Request correlation ID
What to Alert On
Set up automated alerts for:
- Authentication failure spikes (potential brute force)
- Rate limit violations (potential attack or misbehaving client)
- 403 response spikes (potential authorization probe)
- 500 error rate increase (potential exploit or system issue)
- Unusual traffic patterns (volume, geographic origin, time of day)
Security Checklist for Every API
Before deploying any API endpoint:
- Authentication required for non-public endpoints
- Object-level authorization checks on every resource access
- Input validation for all parameters (type, format, length, range)
- Rate limiting configured appropriately
- Response fields minimized to only necessary data
- Error messages do not leak internal details
- Security headers set
- HTTPS enforced (no HTTP)
- Logging captures security-relevant events
- SQL injection prevention through parameterized queries
API security is not a one-time project. It is a continuous practice that must be part of every code review, every deployment, and every architectural decision. The cost of getting it right is small. The cost of getting it wrong can be catastrophic.