The 2025 Verizon Data Breach Investigations Report found that API abuse was the primary attack vector in 38% of data breaches — surpassing phishing for the first time. The reason is straightforward: APIs expose business logic directly, they are designed to be consumed programmatically (making automation trivial for attackers), and many developers treat API security as an afterthought, bolting on authentication after the core functionality is built.
This is not another generic "use HTTPS and validate input" article. This is a 40-point checklist with exact implementation code for the security controls that prevent real breaches. Every check includes the specific vulnerability it prevents, the implementation code, and how to test it. We cover REST APIs, GraphQL APIs, and the intersection of both.
Section 1: Authentication (Checks 1-8)
Check 1: Use short-lived JWTs with refresh token rotation. JWTs should expire in 15 minutes or less. The refresh token should be rotated on every use (one-time use) and stored in an HttpOnly, Secure, SameSite=Strict cookie — never in localStorage or sessionStorage.
// Node.js (Express) — JWT implementation
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
// Access token: 15-minute expiry, contains minimal claims
function generateAccessToken(userId: string, roles: string[]): string {
return jwt.sign(
{ sub: userId, roles, type: 'access' },
process.env.JWT_SECRET!,
{
expiresIn: '15m',
issuer: 'api.yourdomain.com',
audience: 'yourdomain.com',
algorithm: 'ES256', // Use ECDSA, NOT HS256
}
);
}
// Refresh token: 7-day expiry, opaque, stored in DB
function generateRefreshToken(): string {
return crypto.randomBytes(64).toString('base64url');
}
// Set refresh token as HttpOnly cookie:
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only sent to refresh endpoint
domain: '.yourdomain.com',
});
Check 2: Validate JWT algorithm in verification. The "alg: none" attack tricks JWT libraries into accepting unsigned tokens. Always specify the expected algorithm explicitly:
// VULNERABLE — accepts any algorithm the token claims:
jwt.verify(token, secret);
// SECURE — explicitly specifies allowed algorithms:
jwt.verify(token, publicKey, { algorithms: ['ES256'] });
Check 3: Implement API key scoping. API keys should have the minimum permissions required. Never issue a single "master" key with full access:
// API key with explicit permission scopes:
interface ApiKey {
key: string; // The hashed key value
name: string; // Human-readable identifier
scopes: string[]; // ['read:products', 'write:orders']
rateLimit: number; // Requests per minute for this key
allowedIPs: string[]; // IP whitelist (empty = any IP)
expiresAt: Date; // Mandatory expiration
lastUsedAt: Date; // Track usage for rotation alerts
}
Check 4: Hash API keys, never store plaintext. API keys in your database should be hashed with SHA-256. Show the full key to the user exactly once at creation, then store only the hash:
import { createHash } from 'crypto';
function hashApiKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
// At creation: return the key, store the hash
// At verification: hash the incoming key and compare
Check 5-8: Implement PKCE for OAuth public clients, enforce MFA for admin API endpoints, log every authentication event with IP and user agent, and set up automated alerts for impossible travel (same user authenticating from two continents within minutes).
Section 2: Authorization (Checks 9-16)
Check 9: Implement object-level authorization on every endpoint. The #1 API vulnerability (OWASP API Top 10: API1 — Broken Object-Level Authorization) occurs when users can access objects belonging to other users by simply changing an ID in the URL:
// VULNERABLE — no ownership check:
app.get('/api/orders/:id', async (req, res) => {
const order = await db.orders.findById(req.params.id);
res.json(order); // Any authenticated user can see ANY order
});
// SECURE — verify ownership:
app.get('/api/orders/:id', async (req, res) => {
const order = await db.orders.findOne({
_id: req.params.id,
userId: req.user.id, // Only returns if the order belongs to this user
});
if (!order) {
// Return 404, NOT 403 — don't reveal that the resource exists
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
Check 10: Use UUIDs, not sequential integers, for resource IDs. Sequential IDs (1, 2, 3, ...) enable enumeration attacks. A user who knows their order is ID 1547 will try 1548, 1549, etc. UUIDs (550e8400-e29b-41d4-a716-446655440000) make enumeration impractical:
// Use UUID v7 (time-ordered) for database-friendly UUIDs:
import { v7 as uuidv7 } from 'uuid';
const orderId = uuidv7(); // e.g., "018d5f2c-7b3a-7f00-8000-abc123def456"
Check 11: Implement function-level authorization. Admin endpoints must verify the user has the admin role, not just that they are authenticated. Check this in middleware, not in individual handlers, to prevent missed checks:
// Authorization middleware:
function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !roles.some(role => req.user.roles.includes(role))) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage:
app.delete('/api/admin/users/:id', requireRole('admin'), deleteUser);
app.get('/api/admin/audit-log', requireRole('admin', 'security'), getAuditLog);
Check 12-16: Implement field-level access control (users should not see internal fields like isAdmin, passwordHash), enforce rate limits per user/role, return consistent error responses that do not leak authorization details, implement permission caching with short TTL, and test authorization with automated tools like OWASP ZAP or Burp Suite.
Section 3: Input Validation (Checks 17-24)
Check 17: Validate ALL input with a schema validation library. Never trust client input. Use Zod (TypeScript), Pydantic (Python), or go-playground/validator (Go) to validate every request body, query parameter, and path parameter:
// Zod schema validation (TypeScript):
import { z } from 'zod';
const CreateOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
notes: z.string().max(500).optional(),
})).min(1).max(50),
shippingAddress: z.object({
street: z.string().min(5).max(200),
city: z.string().min(2).max(100),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().length(2),
}),
paymentMethodId: z.string().uuid(),
couponCode: z.string().regex(/^[A-Z0-9]{4,20}$/).optional(),
});
// In the handler:
app.post('/api/orders', async (req, res) => {
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten(),
});
}
// result.data is fully typed and validated
const order = await createOrder(result.data);
res.status(201).json(order);
});
Check 18: Sanitize HTML/script content in user-submitted text. Even with validation, user-generated content that will be rendered in a browser must be sanitized. Use DOMPurify on the client and a server-side equivalent:
import createDOMPurify from 'isomorphic-dompurify';
const DOMPurify = createDOMPurify();
function sanitizeUserContent(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false,
});
}
Check 19-24: Enforce Content-Type validation (reject requests with wrong Content-Type), implement request size limits at both the reverse proxy and application level, validate file uploads by magic bytes not just extension, implement SQL parameterized queries everywhere (zero exceptions), validate pagination parameters to prevent resource exhaustion (limit max page size to 100), and reject requests with unexpected fields (strict mode parsing).
Section 4: Rate Limiting and Abuse Prevention (Checks 25-30)
Check 25: Implement tiered rate limiting. Different endpoints need different rate limits. Authentication endpoints should be heavily restricted (5 attempts per minute), while read endpoints can be more permissive:
// Using express-rate-limit with Redis store for distributed rate limiting:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
// Global rate limit: 200 requests per minute
const globalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 200,
standardHeaders: 'draft-7',
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
keyGenerator: (req) => req.user?.id || req.ip,
message: { error: 'Too many requests', retryAfter: 60 },
});
// Auth rate limit: 5 attempts per 15 minutes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
keyGenerator: (req) => req.ip, // By IP, not by user (they're not authenticated yet)
skipSuccessfulRequests: true, // Don't count successful logins
});
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
Check 26-30: Implement per-API-key rate limits stored in the key metadata, add CAPTCHA or proof-of-work challenges after rate limit warnings, use sliding window rate limiting (not fixed window) to prevent burst attacks at window boundaries, implement cost-based rate limiting for GraphQL (complex queries consume more of the budget), and log rate limit violations for security monitoring.
Section 5: GraphQL-Specific Security (Checks 31-36)
Check 31: Disable introspection in production. GraphQL introspection exposes your entire schema to attackers, including internal types, mutations, and field descriptions:
// Apollo Server 4:
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
// For development, use Apollo Sandbox or GraphiQL.
// In production, introspection must be disabled.
Check 32: Implement query depth limiting. Without depth limits, attackers can craft deeply nested queries that exhaust server resources:
// A malicious query without depth limiting:
// { user { friends { friends { friends { friends { ... } } } } } }
// This creates exponential database queries.
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)], // Maximum 5 levels deep
});
Check 33: Implement query complexity analysis. Even with depth limits, a wide query at depth 1 can be expensive. Assign costs to fields and reject queries that exceed a budget:
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const complexityRule = createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10, // Lists multiply the cost of their children
onCost: (cost: number) => {
console.log('Query complexity:', cost);
},
});
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5), complexityRule],
});
Check 34-36: Disable query batching or limit batch size to 5 queries, implement persisted queries (only allow pre-registered query hashes in production), and use DataLoader to prevent N+1 queries that attackers can weaponize for DoS.
Section 6: Headers and Transport Security (Checks 37-40)
Check 37: Set all security headers. Every API response should include these headers:
// Security headers middleware:
app.use((req, res, next) => {
// Prevent MIME type sniffing:
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking:
res.setHeader('X-Frame-Options', 'DENY');
// Enable browser XSS filter:
res.setHeader('X-XSS-Protection', '0'); // Disabled — use CSP instead
// Content Security Policy for API responses:
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
// Strict Transport Security (HSTS):
res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
// Do not leak referrer information:
res.setHeader('Referrer-Policy', 'no-referrer');
// Permissions Policy — disable browser features your API doesn't need:
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Remove server identification headers:
res.removeHeader('X-Powered-By');
res.removeHeader('Server');
next();
});
Check 38: Configure CORS properly. Never use Access-Control-Allow-Origin: * on APIs that use cookies or authentication:
import cors from 'cors';
app.use(cors({
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
}));
Check 39-40: Enforce minimum TLS 1.2 (preferably TLS 1.3 only) at the reverse proxy level, and implement request ID tracing (generate a UUID for each request, include it in all logs, and return it in the response for debugging).
Automated Security Testing
Manual checklists are necessary but insufficient. Integrate automated API security testing into your CI/CD pipeline:
# Run OWASP ZAP API scan in CI:
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t https://api.yourdomain.com/openapi.json \
-f openapi \
-r report.html \
-c zap-config.yaml
# Run Nuclei with API-specific templates:
nuclei -u https://api.yourdomain.com \
-t api/ \
-severity critical,high \
-o nuclei-results.txt
API security is not a feature you add at the end — it is a design principle that shapes every decision from schema design to error handling. This 40-point checklist provides a concrete, actionable baseline. At ZeonEdge, we perform API security audits and implement these controls for teams building production APIs. Learn about our API security audit service.
Sarah Chen
Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.