APIs have overtaken traditional web applications as the most common attack surface. The OWASP API Security Top 10 documents the most critical risks, and real-world breaches at companies like Twitter, Facebook, Peloton, and countless others originated from poorly secured API endpoints.
This guide covers 11 essential API security controls, what can go wrong without them, and how to implement each correctly.
1. Authenticate Every Request
Every API endpoint that handles user data or performs actions must require authentication. The two main approaches:
JWT (JSON Web Tokens)
// Middleware to validate JWT on every protected route
const verifyToken = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]; // Bearer <token>
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
// CRITICAL: always specify algorithm — never let JWT library auto-detect
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Explicitly specify — prevents algorithm confusion attacks
});
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
Common JWT mistakes to avoid:
algorithms: ['none']— allows unsigned tokens- Using symmetric secret for tokens you didn’t issue yourself (use RS256 instead)
- Not validating
exp,nbf,iss,audclaims
API Keys
# API key validation middleware
def require_api_key(f):
@wraps(f)
def decorated_function(*args, **kwargs):
api_key = request.headers.get('X-API-Key')
if not api_key:
return jsonify({'error': 'API key required'}), 401
# Constant-time comparison to prevent timing attacks
if not hmac.compare_digest(api_key, os.environ['VALID_API_KEY']):
return jsonify({'error': 'Invalid API key'}), 401
return f(*args, **kwargs)
return decorated_function
2. Implement Proper Authorization (Not Just Authentication)
Authentication confirms who is making the request. Authorization determines what they’re allowed to do.
The most common API vulnerability: Broken Object Level Authorization (BOLA/IDOR) — accessing another user’s data by changing an ID.
# VULNERABLE — only checks authentication, not ownership
@app.route('/api/orders/<order_id>')
@require_auth
def get_order(order_id):
order = Order.query.get(order_id) # Any authenticated user can access any order
return jsonify(order.to_dict())
# SECURE — checks that the order belongs to the requesting user
@app.route('/api/orders/<order_id>')
@require_auth
def get_order(order_id):
order = Order.query.filter_by(
id=order_id,
user_id=current_user.id # ← Ownership check
).first_or_404()
return jsonify(order.to_dict())
3. Rate Limiting on All Endpoints
Without rate limiting, APIs are vulnerable to:
- Brute force attacks on authentication endpoints
- Credential stuffing
- Scraping of user data
- DoS via expensive operations
// Express — per-endpoint rate limiting
const rateLimit = require('express-rate-limit');
// Strict limits for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per IP per 15 minutes
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true,
});
// More lenient for general API
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
});
app.post('/auth/login', authLimiter, loginHandler);
app.use('/api/', apiLimiter);
4. Validate and Sanitize All Input
Never trust API input — validate type, format, length, and range for every parameter.
// Zod schema validation (TypeScript)
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s'-]+$/),
age: z.number().int().min(0).max(150),
role: z.enum(['user', 'viewer']), // Whitelist allowed values
});
app.post('/api/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
// result.data is now type-safe and validated
await createUser(result.data);
});
5. Avoid Excessive Data Exposure
APIs frequently return more data than the client needs, exposing sensitive fields:
# VULNERABLE — returns entire user object including password hash, internal flags
@app.route('/api/users/<user_id>')
def get_user(user_id):
user = User.query.get_or_404(user_id)
return jsonify(user.__dict__) # Leaks password_hash, is_admin, internal_notes
# SECURE — explicit field selection
@app.route('/api/users/<user_id>')
def get_user(user_id):
user = User.query.get_or_404(user_id)
return jsonify({
'id': user.id,
'name': user.display_name,
'avatar_url': user.avatar_url,
# password_hash, is_admin, etc. intentionally excluded
})
This also applies to error messages — never expose stack traces, database schema details, or internal paths in API error responses.
6. Use HTTPS and Enforce It
All API traffic must be encrypted. Never allow HTTP fallback for API endpoints.
# Nginx — redirect HTTP to HTTPS and set HSTS
server {
listen 80;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
}
7. Configure CORS Correctly
Misconfigured CORS is a common API vulnerability that allows malicious websites to make authenticated requests on behalf of your users.
// DANGEROUS — allows any origin to make credentialed requests
app.use(cors({
origin: '*',
credentials: true, // This combination is actually blocked by browsers, but '*' alone is still dangerous
}));
// SECURE — explicit allowlist
const allowedOrigins = [
'https://app.yourcompany.com',
'https://yourcompany.com',
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));
8. Protect Against Mass Assignment
Mass assignment occurs when API endpoints automatically bind all request body properties to a model, including fields that should never be user-controlled.
# VULNERABLE — user can set is_admin=True in the request body
@app.route('/api/profile', methods=['PUT'])
def update_profile():
user = current_user
user.update(**request.json) # Accepts ANY field, including is_admin, credit_balance
db.session.commit()
# SECURE — explicit allowlist of mutable fields
ALLOWED_PROFILE_FIELDS = {'name', 'bio', 'avatar_url', 'notification_preferences'}
@app.route('/api/profile', methods=['PUT'])
def update_profile():
data = {k: v for k, v in request.json.items() if k in ALLOWED_PROFILE_FIELDS}
current_user.update(**data)
db.session.commit()
9. Implement Security Headers
# Flask — security headers on all API responses
@app.after_request
def add_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Cache-Control'] = 'no-store'
response.headers['Pragma'] = 'no-cache'
# Remove server version disclosure
response.headers.pop('Server', None)
response.headers.pop('X-Powered-By', None)
return response
10. Secure GraphQL APIs
GraphQL requires additional security controls beyond standard REST API hardening:
// Disable introspection in production — prevents schema enumeration
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
// Depth limiting — prevents deeply nested queries that cause DoS
validationRules: [
depthLimit(5),
costAnalysis({ maximumCost: 1000 }),
],
});
// Field-level authorization
const resolvers = {
Query: {
user: async (parent, { id }, context) => {
if (!context.user) throw new AuthenticationError('Login required');
if (context.user.id !== id && !context.user.isAdmin) {
throw new ForbiddenError('Access denied');
}
return User.findById(id);
},
},
};
11. Log and Monitor API Activity
Centralized logging of API activity enables detection of attacks in progress:
import logging
from functools import wraps
def log_api_call(f):
@wraps(f)
def wrapper(*args, **kwargs):
logger.info({
'event': 'api_call',
'endpoint': request.path,
'method': request.method,
'user_id': getattr(current_user, 'id', None),
'ip': request.remote_addr,
'user_agent': request.user_agent.string,
})
try:
response = f(*args, **kwargs)
logger.info({'event': 'api_response', 'status': response.status_code})
return response
except Exception as e:
logger.error({'event': 'api_error', 'error': str(e)})
raise
return wrapper
Monitor for:
- More than 5 authentication failures from one IP in 5 minutes
- Successful login followed by rapid enumeration of object IDs
- Unusual data volume in responses (potential mass data extraction)
- Requests to admin endpoints from non-admin accounts
OWASP API Security Top 10 Reference
| # | Risk | Control |
|---|---|---|
| API1 | Broken Object Level Authorization | Practice 2 (ownership checks) |
| API2 | Broken Authentication | Practice 1 (JWT/API key validation) |
| API3 | Broken Object Property Level Authorization | Practice 8 (mass assignment protection) |
| API4 | Unrestricted Resource Consumption | Practice 3 (rate limiting) |
| API5 | Broken Function Level Authorization | Practice 2 (role-based access) |
| API6 | Unrestricted Access to Sensitive Business Flows | Practice 3 + business logic testing |
| API7 | Server-Side Request Forgery | Input validation + network egress control |
| API8 | Security Misconfiguration | Practices 6, 7, 9 |
| API9 | Improper Inventory Management | Asset inventory + API versioning |
| API10 | Unsafe Consumption of APIs | Validate third-party API responses |
Offensive360 DAST tests your live API endpoints for all OWASP API Security Top 10 risks. Run a DAST scan on your API or contact us to discuss API security testing.