TL;DR
The #1 API security best practice is defense in depth. Authenticate every request, authorize access to specific resources, validate all inputs, rate limit to prevent abuse, and never expose sensitive data in responses. These practices prevent 88% of API security incidents. Total time to implement: ~36 minutes.
"APIs are the front doors, back doors, and windows of modern applications. Lock every single one of them."
The API Security Checklist
Every API endpoint should:
- Verify authentication (who is the user?)
- Check authorization (can they access this resource?)
- Validate input data (is the request well-formed?)
- Sanitize output (does the response contain sensitive data?)
- Log the request (for auditing and debugging)
- Handle errors gracefully (without leaking information)
Best Practice 1: Authenticate Every Request 5 min
Never trust that a request is authenticated. Verify on every call:
import jwt from 'jsonwebtoken';
async function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Apply to protected routes
app.use('/api/protected', authenticate);
Best Practice 2: Implement Authorization 5 min
Authentication is not enough. Verify the user can access the specific resource:
app.get('/api/posts/:id', authenticate, async (req, res) => {
const post = await db.post.findUnique({
where: { id: req.params.id }
});
if (!post) {
return res.status(404).json({ error: 'Not found' });
}
// Authorization: Check if user can access this post
if (post.authorId !== req.user.id && !post.isPublished) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(post);
});
app.put('/api/posts/:id', authenticate, async (req, res) => {
const post = await db.post.findUnique({
where: { id: req.params.id }
});
if (!post) {
return res.status(404).json({ error: 'Not found' });
}
// Only author can update
if (post.authorId !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
// Proceed with update...
});
Best Practice 3: Validate All Input 5 min
Never trust client data. Validate everything:
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(3).max(200),
content: z.string().max(50000),
tags: z.array(z.string()).max(10).optional(),
});
app.post('/api/posts', authenticate, async (req, res) => {
// Validate input
const result = createPostSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Invalid input',
details: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
// Use validated data
const post = await db.post.create({
data: {
...result.data,
authorId: req.user.id, // Never use client-provided user ID
},
});
res.status(201).json(post);
});
Best Practice 4: Use Parameterized Queries 3 min
Prevent SQL injection by using parameterized queries or ORMs:
// WRONG: SQL injection vulnerability
const query = `SELECT * FROM users WHERE id = ${userId}`;
// CORRECT: Parameterized query
const result = await db.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
// ALSO CORRECT: Using an ORM like Prisma
const user = await prisma.user.findUnique({
where: { id: userId }
});
Best Practice 5: Rate Limit Your API 5 min
Prevent abuse and brute force attacks with rate limiting:
import rateLimit from 'express-rate-limit';
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests' },
standardHeaders: true,
});
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 attempts
message: { error: 'Too many login attempts' },
});
// Even stricter for password reset
const passwordResetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
message: { error: 'Too many password reset requests' },
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/reset-password', passwordResetLimiter);
Best Practice 6: Handle Errors Safely 5 min
Error messages should not expose internal details:
// Global error handler
app.use((err, req, res, next) => {
// Log full error for debugging
console.error('API Error:', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
userId: req.user?.id,
});
// Send generic message to client
if (err.name === 'ValidationError') {
return res.status(400).json({ error: 'Invalid request data' });
}
if (err.name === 'UnauthorizedError') {
return res.status(401).json({ error: 'Authentication required' });
}
// Generic error for unexpected issues
res.status(500).json({ error: 'An unexpected error occurred' });
});
Never expose: Stack traces, database errors, file paths, or internal system details in API responses. These help attackers understand your system.
Best Practice 7: Sanitize Response Data 5 min
Remove sensitive fields before sending responses:
// Define what fields to expose
function sanitizeUser(user) {
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
// Never include: password, tokens, internal IDs
};
}
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await db.user.findUnique({
where: { id: req.params.id }
});
if (!user) {
return res.status(404).json({ error: 'Not found' });
}
// Sanitize before sending
res.json(sanitizeUser(user));
});
// Or use Prisma select to only fetch needed fields
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
// password is never selected
},
});
Best Practice 8: Use HTTPS Only 3 min
All API traffic should be encrypted:
// Redirect HTTP to HTTPS
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(`https://${req.hostname}${req.url}`);
}
next();
});
// Add security headers
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
Common API Security Mistakes
| Mistake | Impact | Prevention |
|---|---|---|
| Missing authentication | Unauthorized access | Auth middleware on all routes |
| No authorization checks | Data from other users | Verify resource ownership |
| Trusting client user ID | Impersonation | Use server-side session |
| Verbose error messages | Information disclosure | Generic client messages |
| No rate limiting | Brute force, DoS | Implement rate limits |
External Resources: For comprehensive API security guidance, see the OWASP API Security Top 10 and the REST Security Cheat Sheet . These resources provide industry-standard security recommendations.
Should I use JWT or sessions for API auth?
Both can be secure. JWTs work well for stateless APIs and microservices. Sessions (stored server-side) offer easier revocation. For SPAs, consider HttpOnly cookies with session tokens for better XSS protection.
How strict should rate limiting be?
It depends on your use case. Public APIs: 100-1000 requests/hour. Authenticated APIs: higher limits per user. Auth endpoints: very strict (5-10 attempts/15 minutes). Start strict and relax based on legitimate usage patterns.
Should I validate on client and server?
Yes, both. Client validation improves user experience with instant feedback. Server validation is required for security since client validation can be bypassed. Never skip server-side validation.
How do I handle API versioning securely?
Use URL versioning (/api/v1/) or headers. When deprecating versions, give notice and maintain security patches until end-of-life. Never expose internal version numbers that reveal your technology stack.
Verify Your API Security
Scan your API endpoints for security issues and vulnerabilities.
Start Free Scan