TL;DR
The #1 API security best practice is defense in depth. Authenticate every request, authorize access to the specific resource, validate all inputs, rate limit to prevent abuse, and keep sensitive fields out of responses. None of those is enough on its own, which is the point. 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 assume a request is authenticated just because it reached a protected route. Check 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
Knowing who someone is doesn't tell you what they're allowed to touch. That's a second check, and it's the one that gets skipped.
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
Client data is a suggestion. Validate it.
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
Parameterized queries and ORMs both keep user input out of the SQL itself, which is the entire defense against injection.
// 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
Rate limits are what stop one script from trying ten thousand passwords overnight.
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
An error message is a response to an attacker too. Say less.
// 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
Fetching a whole user row and returning it is how password hashes end up in a JSON response. Pick the fields deliberately.
// 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
Everything on the wire should be encrypted, including the redirect that gets you there.
// 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. Both are industry-standard references.
Should I use JWT or sessions for API auth?
Both can be secure. JWTs suit stateless APIs and microservices. Server-side sessions are easier to revoke, which matters more than it sounds the day you need to kick someone out right now. For SPAs, HttpOnly cookies with session tokens give you 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 is for the user, giving instant feedback. Server validation is for security, because anything running on the client can be bypassed. Never skip the server side.
How do I handle API versioning securely?
Use URL versioning (/api/v1/) or headers. When you deprecate a version, give notice and keep patching it until end-of-life. Don't expose internal build numbers that give away your stack.
Further Reading
Put these practices into action with our step-by-step guides.
Verify Your API Security
Scan your API endpoints for security issues and vulnerabilities.