API security failures now account for the majority of data breaches. The 2023 OWASP API Security Top 10 documents the most critical risks, and real-world incidents at Twitter, T-Mobile, Optus, and countless others trace back to a handful of preventable mistakes in API design and implementation.
This guide covers the essential API security best practices every development team should implement in 2026 — with concrete code examples, common pitfalls, and how to test your APIs for each weakness.
Why API Security Is Different from Web App Security
APIs introduce a distinct set of security challenges that traditional web application security guidance doesn’t fully address:
- No browser-enforced protections — APIs are consumed by mobile apps, scripts, and third-party integrations that don’t have browser security policies
- Richer data access — APIs typically expose direct access to data models, making over-exposure of sensitive fields easy to overlook
- Multiple consumers — a single API serves web apps, mobile apps, partner integrations, and internal microservices — each with different trust levels
- Documentation exposes the attack surface — Swagger/OpenAPI specs and GraphQL introspection give attackers a complete map of your endpoints
The OWASP API Security Top 10 was created precisely because standard web application guidance is insufficient for modern APIs.
1. Enforce Authentication on Every Endpoint
Every API endpoint that accesses user data or performs any action must require authentication. Unauthenticated endpoints should be an explicit, reviewed decision — not an oversight.
JWT Authentication
JSON Web Tokens are the most common API authentication mechanism. The critical security requirements:
// SECURE JWT validation — Node.js/Express
const verifyToken = (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, {
algorithms: ['HS256'], // CRITICAL: always specify algorithms explicitly
issuer: 'api.yourapp.com',
audience: 'yourapp-clients',
});
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
};
JWT security pitfalls to avoid:
algorithms: ['none']— allows attackers to submit unsigned tokens- Not validating
exp(expiry),iss(issuer), oraud(audience) claims - Storing JWT secrets in source code instead of environment variables
- Using the same secret for signing and verification across multiple services
API Key Authentication
For machine-to-machine APIs and partner integrations:
import hmac
import os
def require_api_key(f):
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key')
if not api_key:
return jsonify({'error': 'API key required'}), 401
# Use constant-time comparison to prevent timing attacks
valid_key = os.environ['API_SECRET_KEY']
if not hmac.compare_digest(api_key.encode(), valid_key.encode()):
return jsonify({'error': 'Invalid API key'}), 401
return f(*args, **kwargs)
return decorated
2. Implement Object-Level Authorization (Fix BOLA)
Broken Object Level Authorization (BOLA) — also called IDOR (Insecure Direct Object Reference) — is the #1 risk in the OWASP API Security Top 10. It occurs when an API endpoint accepts an object identifier in the request but fails to verify that the requesting user has permission to access that specific object.
# VULNERABLE — authenticates the user but doesn't check ownership
@app.route('/api/v1/invoices/<int:invoice_id>')
@require_auth
def get_invoice(invoice_id):
invoice = Invoice.query.get_or_404(invoice_id)
return jsonify(invoice.to_dict())
# Any authenticated user can access ANY invoice by changing the ID
# SECURE — always filter by the authenticated user's identity
@app.route('/api/v1/invoices/<int:invoice_id>')
@require_auth
def get_invoice(invoice_id):
invoice = Invoice.query.filter_by(
id=invoice_id,
organization_id=current_user.organization_id # Ownership check
).first_or_404()
return jsonify(invoice.to_dict())
Testing for BOLA:
- Authenticate as User A and retrieve a resource ID (e.g.,
/api/orders/12345) - Authenticate as User B and attempt to access the same ID
- If User B gets a 200 response, the endpoint is vulnerable
3. Rate Limit All Endpoints — Especially Authentication
Without rate limiting, APIs are vulnerable to brute force attacks, credential stuffing, enumeration, and denial-of-service.
// Express rate limiting — different limits per endpoint sensitivity
const rateLimit = require('express-rate-limit');
// Very strict for authentication endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per IP
skipSuccessfulRequests: false,
message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
// Moderate for general API
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 120, // 120 requests per minute per IP
standardHeaders: true,
});
// Strict for password reset / account creation (anti-enumeration)
const sensitiveActionLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
});
app.post('/api/auth/login', authLimiter, loginController);
app.post('/api/auth/forgot-password', sensitiveActionLimiter, forgotPasswordController);
app.post('/api/auth/register', sensitiveActionLimiter, registerController);
app.use('/api/', apiLimiter);
For distributed systems, use Redis-backed rate limiting (e.g., rate-limit-redis) to enforce limits across multiple server instances.
4. Validate and Sanitize All Input
Never trust API input regardless of where it originates — this applies to mobile app requests, partner integrations, and internal service calls.
// TypeScript + Zod — schema validation at the API boundary
import { z } from 'zod';
const CreateOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100), // Reasonable upper bound
shippingAddress: z.object({
street: z.string().min(1).max(200),
city: z.string().min(1).max(100),
countryCode: z.string().length(2).toUpperCase(),
postalCode: z.string().regex(/^[A-Z0-9\s-]{3,10}$/i),
}),
couponCode: z.string().max(50).optional(),
});
app.post('/api/orders', requireAuth, async (req, res) => {
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues,
});
}
// result.data is now fully validated and type-safe
const order = await createOrder(result.data, req.user);
return res.status(201).json(order);
});
What to validate:
- Type (string, integer, boolean)
- Format (email, UUID, ISO date, country code)
- Length/range bounds
- Allowed values (enums, allowlists)
- Character restrictions (especially for fields used in further processing)
5. Prevent Excessive Data Exposure
APIs frequently return entire data objects when only a subset is needed. This exposes sensitive fields to clients — and to attackers who intercept responses.
# VULNERABLE — returns raw model including sensitive internal fields
class UserResource(Resource):
def get(self, user_id):
user = User.query.get_or_404(user_id)
return user.__dict__
# Returns: id, email, password_hash, is_admin, internal_score,
# created_at, last_login_ip, mfa_secret, ...
# SECURE — explicit response schema
from marshmallow import Schema, fields
class PublicUserSchema(Schema):
id = fields.Int()
display_name = fields.Str()
avatar_url = fields.Str()
joined_date = fields.Date()
# password_hash, is_admin, mfa_secret — intentionally excluded
public_user_schema = PublicUserSchema()
class UserResource(Resource):
def get(self, user_id):
user = User.query.get_or_404(user_id)
return public_user_schema.dump(user)
Also applies to error responses. Never expose stack traces, database schema names, internal file paths, or server version information in API error messages.
6. Fix CORS Configuration
Permissive CORS configurations allow any website to make cross-origin requests to your API using a visitor’s authenticated session.
// DANGEROUS — allows any origin with credentials
app.use(cors({
origin: '*',
}));
// ALSO DANGEROUS — reflects the request origin without validation
app.use(cors({
origin: (origin, callback) => callback(null, true), // Reflects everything
credentials: true,
}));
// SECURE — explicit allowlist
const ALLOWED_ORIGINS = new Set([
'https://app.yourcompany.com',
'https://yourcompany.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean));
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, mobile apps)
if (!origin || ALLOWED_ORIGINS.has(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: origin ${origin} not permitted`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
}));
7. Protect Against Mass Assignment
Mass assignment occurs when an API automatically binds all request properties to a data model, including fields that should never be user-controlled — such as is_admin, role, account_balance, or email_verified.
# VULNERABLE — user can set is_admin=true or role=admin in the request
@app.route('/api/users/me', methods=['PATCH'])
@require_auth
def update_profile():
current_user.update(**request.json) # Accepts ALL fields
db.session.commit()
return jsonify(current_user.to_dict())
# SECURE — allowlist the only fields users should be able to change
MUTABLE_PROFILE_FIELDS = frozenset({
'display_name',
'bio',
'avatar_url',
'notification_preferences',
'timezone',
})
@app.route('/api/users/me', methods=['PATCH'])
@require_auth
def update_profile():
# Only apply fields from the allowlist
updates = {k: v for k, v in request.json.items()
if k in MUTABLE_PROFILE_FIELDS}
if not updates:
return jsonify({'error': 'No valid fields to update'}), 400
current_user.update(**updates)
db.session.commit()
return jsonify(current_user.to_dict())
8. Secure GraphQL APIs
GraphQL requires security controls beyond standard REST API hardening because its flexible query language enables new attack vectors.
// Apollo Server — production security configuration
const server = new ApolloServer({
typeDefs,
resolvers,
// Disable introspection in production — prevents schema enumeration
introspection: process.env.NODE_ENV !== 'production',
plugins: [
// Depth limiting — prevents deeply nested queries that cause DoS
ApolloServerPluginDepthLimit(5),
],
});
// Query complexity analysis — block expensive queries
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
validationRules: [
createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query cost:', cost),
}),
],
});
// Field-level authorization
const resolvers = {
Query: {
user: async (_, { id }, context) => {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Only allow access to your own profile (or admin access)
if (context.user.id !== id && context.user.role !== 'admin') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return User.findById(id);
},
},
};
9. Use HTTPS and Enforce TLS Everywhere
All API traffic — including internal service-to-service communication — must be encrypted.
# Nginx — force HTTPS and set HSTS with preload
server {
listen 80;
server_name api.yourcompany.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.yourcompany.com;
# HSTS — 2 years, include subdomains, preload
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# TLS 1.2+ only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
}
For internal microservice communication, use mutual TLS (mTLS) where services authenticate each other with client certificates — not just the server to the client.
10. Add Security Headers to API Responses
# Flask — security headers for all API responses
from flask import Flask
app = Flask(__name__)
@app.after_request
def security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Remove information-disclosing headers
response.headers.pop('Server', None)
response.headers.pop('X-Powered-By', None)
return response
11. Log, Monitor, and Alert on API Activity
Without visibility into what your APIs are doing, you cannot detect attacks in progress or investigate incidents after the fact.
What to log:
- Every authentication attempt (success and failure) with IP, user agent, timestamp
- Authorization failures (who tried to access what they shouldn’t)
- Rate limit triggers (potential brute force or scraping)
- Any request to a sensitive endpoint (admin functions, bulk exports, password changes)
Do NOT log:
- Passwords or authentication tokens
- Full credit card numbers or other PAN data
- Health record data covered by HIPAA
- Any field that constitutes PII unless required for the specific log’s purpose
import structlog
logger = structlog.get_logger()
@app.before_request
def log_request():
logger.info(
"api_request",
method=request.method,
path=request.path,
ip=request.remote_addr,
user_id=getattr(current_user, 'id', None),
# DO NOT log: request body (may contain passwords), full auth token
)
@app.after_request
def log_response(response):
if response.status_code >= 400:
logger.warning(
"api_error_response",
status=response.status_code,
path=request.path,
ip=request.remote_addr,
)
return response
Alerting rules to configure:
- 5+ authentication failures from the same IP in 5 minutes → potential brute force
- Authentication success after 3+ failures → credential stuffing success
- Rapid sequential enumeration of object IDs → BOLA scraping
- Unusual response data volume → potential mass data extraction
API Security Checklist Summary
Use this checklist before deploying any API to production:
| Category | Check |
|---|---|
| Authentication | Every protected endpoint requires a valid token or API key |
| Authentication | JWT algorithm is explicitly specified — never none |
| Authentication | Token expiry (exp) is validated and enforced |
| Authorization | Object-level checks verify ownership on every resource request |
| Authorization | Admin/privileged endpoints have explicit role checks |
| Rate Limiting | Auth endpoints limited to ≤10 attempts per 15 minutes per IP |
| Rate Limiting | General endpoints have a per-IP request limit |
| Input Validation | All inputs validated for type, format, length, and range |
| Data Exposure | Response schemas are explicit — no raw model serialization |
| CORS | Access-Control-Allow-Origin is an explicit allowlist, not * |
| Mass Assignment | Field allowlists prevent user-controlled sensitive field updates |
| Transport | TLS 1.2+ enforced; HTTP redirects to HTTPS; HSTS set |
| Security Headers | X-Content-Type-Options, Cache-Control: no-store on all responses |
| Logging | Auth failures, authorization denials, and rate limit hits are logged |
| Testing | BOLA tested by accessing resources with a different user’s session |
How Offensive360 Tests API Security
Offensive360’s DAST scanner tests running APIs for all OWASP API Security Top 10 risks automatically:
- BOLA/IDOR testing — parameterizes object IDs and tests cross-user access
- Authentication bypass — tests for missing auth, JWT algorithm confusion, expired token acceptance
- Injection testing — SQL injection, command injection, and NoSQL injection across all parameters
- Rate limit verification — confirms rate limiting is enforced at the HTTP layer
- CORS misconfiguration — tests origin reflection, wildcard with credentials
- Security header checks — validates all required headers are present and correctly configured
- Excessive data exposure — flags responses containing sensitive field patterns
The DAST scan runs against your live API (staging or production) and produces findings mapped to OWASP API Security, CWE, and PCI-DSS controls — with remediation guidance for each finding.
Run a one-time API security scan for $500 — results within 48 hours, no subscription required. Or book a demo to see a live scan of your API.