APIs are the backbone of modern applications. They connect your mobile apps, web frontends, third-party integrations, and microservices. But with great connectivity comes great responsibility—and great risk.
In my experience testing APIs for various organizations, I've seen the same vulnerabilities appear repeatedly. Let's break down the essential practices to secure your APIs.
The API Security Landscape
APIs face unique security challenges:
- Exposed by design - They're meant to be accessible
- Machine-to-machine - Harder to detect abuse patterns
- Data-rich - Often expose sensitive information
- Rapidly evolving - New endpoints added frequently
OWASP API Security Top 10
The OWASP API Security Project identifies the most critical risks:
- Broken Object Level Authorization
- Broken Authentication
- Broken Object Property Level Authorization
- Unrestricted Resource Consumption
- Broken Function Level Authorization
- Unrestricted Access to Sensitive Business Flows
- Server Side Request Forgery
- Security Misconfiguration
- Improper Inventory Management
- Unsafe Consumption of APIs
Authentication Best Practices
Use Modern Authentication Standards
// Good: Using JWT with proper validation
import jwt from 'jsonwebtoken';
function verifyToken(token: string) {
return jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Specify allowed algorithms
issuer: 'your-app-name',
audience: 'your-api',
});
}Implement Proper Token Management
| Practice | Why It Matters |
|---|---|
| Short expiration | Limits window of compromise |
| Refresh tokens | Allow re-authentication without credentials |
| Token revocation | Handle compromised tokens |
| Secure storage | Prevent token theft |
Never Trust Client-Side Validation
// Bad: Trusting user-provided IDs
app.get('/api/users/:id', (req, res) => {
const user = db.users.findById(req.params.id);
res.json(user); // Anyone can access any user!
});
// Good: Verify ownership
app.get('/api/users/:id', authenticate, (req, res) => {
if (req.params.id !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
const user = db.users.findById(req.params.id);
res.json(user);
});Authorization: The Forgotten Security Layer
Authorization bugs are the #1 API vulnerability. Here's how to prevent them:
Implement Object-Level Authorization
Every request must verify the user has access to the specific resource:
// Middleware for resource ownership
async function verifyOwnership(req, res, next) {
const resource = await Resource.findById(req.params.id);
if (!resource) {
return res.status(404).json({ error: 'Not found' });
}
if (resource.ownerId !== req.user.id) {
// Log the attempt for security monitoring
logger.warn('Unauthorized access attempt', {
userId: req.user.id,
resourceId: req.params.id,
});
return res.status(403).json({ error: 'Forbidden' });
}
req.resource = resource;
next();
}Function-Level Authorization
Not every user should access every endpoint:
// Role-based access control
const ROLE_PERMISSIONS = {
user: ['read:own', 'write:own'],
admin: ['read:all', 'write:all', 'delete:all'],
};
function requirePermission(permission) {
return (req, res, next) => {
const userPermissions = ROLE_PERMISSIONS[req.user.role] || [];
if (!userPermissions.includes(permission)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage
app.delete('/api/users/:id',
authenticate,
requirePermission('delete:all'),
deleteUser
);Input Validation and Sanitization
Validate Everything
import { z } from 'zod';
// Define strict schemas
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
password: z.string().min(12).max(128),
role: z.enum(['user', 'admin']).default('user'),
});
// Validate in route handler
app.post('/api/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues,
});
}
// Use validated data
const user = await createUser(result.data);
res.status(201).json(user);
});Prevent SQL Injection
Always use parameterized queries:
// Bad: String concatenation
const query = `SELECT * FROM users WHERE id = ${userId}`;
// Good: Parameterized query
const user = await db.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
// Better: Use an ORM with built-in protection
const user = await prisma.user.findUnique({
where: { id: userId },
});Rate Limiting and Throttling
Protect your API from abuse:
import rateLimit from 'express-rate-limit';
// Global rate limit
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, please try again later' },
});
// Stricter limit for sensitive endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 attempts per hour
message: { error: 'Too many login attempts' },
});
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);Secure Response Handling
Don't Leak Sensitive Data
// Bad: Exposing internal data
res.json(user); // Includes password hash, internal IDs, etc.
// Good: Explicit field selection
res.json({
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
});
// Better: Use DTOs/transformers
res.json(UserDTO.fromEntity(user));Handle Errors Gracefully
// Bad: Exposing stack traces
app.use((err, req, res, next) => {
res.status(500).json({ error: err.stack });
});
// Good: Generic error messages
app.use((err, req, res, next) => {
// Log full error internally
logger.error('API Error', {
error: err,
requestId: req.id,
path: req.path,
});
// Return safe message to client
res.status(500).json({
error: 'An unexpected error occurred',
requestId: req.id, // For support reference
});
});Security Headers
Always set proper headers:
import helmet from 'helmet';
app.use(helmet());
// Additional API-specific headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Cache-Control', 'no-store');
next();
});Logging and Monitoring
You can't secure what you can't see:
// Log all API requests
app.use((req, res, next) => {
const startTime = Date.now();
res.on('finish', () => {
logger.info('API Request', {
method: req.method,
path: req.path,
statusCode: res.statusCode,
duration: Date.now() - startTime,
userId: req.user?.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
});
next();
});Monitor for Suspicious Activity
Watch for patterns like:
- Multiple failed authentication attempts
- Unusual access patterns (time, location, volume)
- Attempts to access unauthorized resources
- Malformed requests or injection attempts
API Security Checklist
Before deploying any API:
- Authentication required for all non-public endpoints
- Authorization checks on every request
- Input validation with strict schemas
- Rate limiting configured
- Error messages don't leak sensitive info
- Security headers set
- Logging and monitoring in place
- HTTPS enforced
- API documentation secured (not public in production)
- Regular security testing scheduled
The Bottom Line
API security isn't optional—it's essential. The good news is that most API vulnerabilities are preventable with proper design and implementation.
Start with authentication and authorization, validate all inputs, and never trust the client. From there, layer in monitoring, rate limiting, and regular testing.
Need help securing your APIs? I specialize in API security assessments and can help identify vulnerabilities before attackers do. Get in touch to discuss your security needs.
Was this article helpful?
Patrick Ejelle-Ndille
Author
Cybersecurity analyst and developer helping businesses secure their digital assets.
About Me