In This Guide
- 1. The API Threat Landscape — What's Actually Getting Exploited
- 2. Authentication — API Keys, OAuth 2.0, JWTs Done Right
- 3. Authorization — BOLA Is the #1 API Vulnerability
- 4. Input Validation and Schema Enforcement
- 5. Rate Limiting and Throttling
- 6. CORS — Stop Misconfiguring It
- 7. Logging, Monitoring, and Incident Detection
- 8. API Gateway Security Patterns
- 9. The API Security Checklist
- 10. Frequently Asked Questions
APIs account for over 83% of internet traffic (Akamai, 2024), and the OWASP API Security Top 10 reads like a playbook for how breaches actually happen — broken object-level authorization, mass assignment, unrestricted resource consumption. We've audited APIs that had proper frontend authentication but zero server-side checks. An attacker doesn't use your UI. They use curl.
This guide covers the patterns we implement at Pillai Infotech for every API we build — from authentication architecture to rate limiting strategies, with real code you can adapt.
1. The API Threat Landscape — What's Actually Getting Exploited
The OWASP API Security Top 10 (2023) is the baseline. Here's what each vulnerability looks like in practice:
| # | Vulnerability | Real-World Attack | Defense |
|---|---|---|---|
| API1 | BOLA | Change /users/123/orders to /users/456/orders → see someone else's orders |
Check ownership on every object access |
| API2 | Broken Auth | Credential stuffing against /login with no rate limit |
MFA, rate limiting, account lockout |
| API3 | Object Property Level | API returns is_admin, ssn, internal fields in response |
Explicit response schemas, never return full DB rows |
| API4 | Unrestricted Consumption | GET /search?q=*&limit=999999 → server OOM |
Max page size, rate limits, resource quotas |
| API5 | Broken Function Level Auth | Regular user calls DELETE /admin/users/123 |
Role-based middleware on every route |
| API6 | Server-Side Request Forgery | {"url": "http://169.254.169.254/metadata"} → AWS keys leaked |
URL allowlist, block internal IPs, IMDSv2 |
| API7 | Security Misconfiguration | Debug mode on, stack traces in production, permissive CORS | Environment-specific configs, security headers |
| API8 | Lack of Protection from Automated Threats | Bots scraping /products, price comparison services hammering API |
Bot detection, CAPTCHA, behavioral analysis |
| API9 | Improper Inventory Mgmt | Old /api/v1/ still live with no auth while v3 is secured |
API versioning policy, sunset old versions |
| API10 | Unsafe Consumption of APIs | Trusting third-party API response without validation → stored XSS | Validate and sanitize all external API responses |
Notice: 6 of 10 are authorization and access control issues, not encryption or cryptography. API security is fundamentally about who can access what, not about what cipher suite you're using.
2. Authentication — API Keys, OAuth 2.0, JWTs Done Right
Different authentication mechanisms serve different purposes. Here's when to use each:
| Method | Use Case | Pros | Cons |
|---|---|---|---|
| API Keys | Server-to-server, internal services | Simple, fast, easy to rotate | No user identity, easy to leak |
| OAuth 2.0 + PKCE | User-facing apps, third-party integrations | Delegated auth, scoped access, standard | Complex flow, token management |
| JWT (Bearer) | Stateless microservice auth | No session store, carries claims | Can't revoke instantly, size in headers |
| mTLS | Zero-trust service mesh, high-security internal | Strongest machine identity | Certificate management overhead |
JWT Implementation — The Right Way
We see the same JWT mistakes repeatedly. Here's what a secure implementation looks like:
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
// ✅ Use RS256 (asymmetric) for multi-service architectures
// ✅ Use HS256 (symmetric) only for single-service
const ACCESS_TOKEN_EXPIRY = '15m'; // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d'; // Longer, stored securely
function generateTokenPair(user) {
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
roles: user.roles, // Only include what consumers need
// ❌ Never: password, ssn, full profile
},
process.env.JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: 'api.pillaiinfotech.com',
audience: 'pillaiinfotech.com',
}
);
const refreshToken = randomBytes(40).toString('hex');
// Store refresh token in DB — enables revocation
storeRefreshToken(user.id, refreshToken, '7d');
return { accessToken, refreshToken };
}
// Middleware — verify on every request
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
const decoded = jwt.verify(header.slice(7), process.env.JWT_SECRET, {
algorithms: ['HS256'], // ✅ Whitelist algorithms
issuer: 'api.pillaiinfotech.com',
audience: 'pillaiinfotech.com',
});
req.user = decoded;
next();
} catch (err) {
// ❌ Don't tell attacker WHY it failed
return res.status(401).json({ error: 'Invalid token' });
}
}
Common JWT mistakes we catch in security audits:
- Using
algorithm: 'none'— the library accepts unsigned tokens - Not whitelisting algorithms — attacker switches RS256 to HS256 using the public key as secret
- Storing sensitive data in JWT payload (it's base64-encoded, not encrypted)
- Access tokens with 24-hour expiry — if leaked, attacker has a full day
- Storing JWTs in localStorage — any XSS vulnerability steals them
3. Authorization — BOLA Is the #1 API Vulnerability
Broken Object Level Authorization (BOLA) is the most common API vulnerability because it's easy to miss. The API authenticates the user (confirms who they are) but doesn't check if they should access that specific resource.
// ❌ VULNERABLE — any authenticated user can access any order
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.query('SELECT * FROM orders WHERE id = ?',
[req.params.orderId]);
return res.json(order);
});
// ✅ FIXED — always filter by the authenticated user
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = ? AND user_id = ?',
[req.params.orderId, req.user.sub] // ← ownership check
);
if (!order) return res.status(404).json({ error: 'Not found' });
return res.json(order);
});
// For admin access to any resource — explicit role check
app.get('/api/admin/orders/:orderId', authenticate, authorize('admin'),
async (req, res) => {
const order = await db.query('SELECT * FROM orders WHERE id = ?',
[req.params.orderId]);
return res.json(order);
}
);
Authorization Middleware Pattern
function authorize(...requiredRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
const hasRole = requiredRoles.some(role =>
req.user.roles.includes(role)
);
if (!hasRole) {
// Log the attempt — could indicate enumeration
logger.warn('Authorization denied', {
user: req.user.sub,
required: requiredRoles,
path: req.path,
ip: req.ip,
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
We audit every API endpoint with a "change-the-ID" test before launch. Literally: authenticate as User A, call every endpoint with User B's resource IDs. If anything returns data, it's a BOLA vulnerability. Takes 30 minutes and catches issues that automated scanners miss.
4. Input Validation and Schema Enforcement
Never trust client input. Every field, every parameter, every header. Validate at the API boundary — not deep inside your business logic.
import { z } from 'zod';
const CreateOrderSchema = z.object({
product_id: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
shipping_address: z.object({
line1: z.string().min(1).max(200),
line2: z.string().max(200).optional(),
city: z.string().min(1).max(100),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().length(2),
}),
// ❌ Never accept: user_id, price, discount, role from client
});
app.post('/api/orders', authenticate, async (req, res) => {
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
// result.data is typed and sanitized — safe to use
const order = await createOrder(req.user.sub, result.data);
return res.status(201).json(order);
});
Mass Assignment Prevention
Mass assignment happens when your API blindly passes request body to the database. This is how attackers set is_admin: true on their own account.
// ❌ VULNERABLE — accepts anything the client sends
$db->execute(
"UPDATE users SET " . buildSetClause($requestBody) . " WHERE id = ?",
[$userId]
);
// ✅ SAFE — explicit allowlist of updatable fields
$allowed = ['name', 'email', 'phone', 'timezone'];
$updates = array_intersect_key($requestBody, array_flip($allowed));
if (empty($updates)) {
return jsonResponse(400, ['error' => 'No valid fields to update']);
}
$setClauses = [];
$params = [];
foreach ($updates as $field => $value) {
$setClauses[] = "$field = ?";
$params[] = $value;
}
$params[] = $userId;
$db->execute(
"UPDATE users SET " . implode(', ', $setClauses) . " WHERE id = ?",
$params
);
5. Rate Limiting and Throttling
Without rate limiting, your API is an open invitation for credential stuffing, scraping, and DDoS. But not all endpoints need the same limits.
| Endpoint Type | Rate Limit | Key By | Why |
|---|---|---|---|
| Login / Auth | 5 req/min | IP + username | Prevent credential stuffing |
| Password Reset | 3 req/hour | IP + email | Prevent email bombing |
| API Read | 100 req/min | API key / user ID | Prevent scraping and abuse |
| API Write | 30 req/min | User ID | Prevent spam / data pollution |
| File Upload | 5 req/min | User ID | Prevent storage abuse |
| Search | 20 req/min | IP + user ID | Expensive queries, prevent enumeration |
import Redis from 'ioredis';
const redis = new Redis();
async function rateLimit(key, maxRequests, windowSecs) {
const now = Date.now();
const windowStart = now - windowSecs * 1000;
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, windowStart); // Remove old entries
pipeline.zadd(key, now, `${now}-${Math.random()}`); // Add current
pipeline.zcard(key); // Count in window
pipeline.expire(key, windowSecs); // Auto-cleanup
const results = await pipeline.exec();
const count = results[2][1];
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
resetAt: new Date(now + windowSecs * 1000),
};
}
// Middleware
async function rateLimitMiddleware(req, res, next) {
const key = `rl:${req.path}:${req.user?.sub || req.ip}`;
const { allowed, remaining, resetAt } = await rateLimit(key, 100, 60);
// Always set headers — helps legitimate clients back off
res.set('X-RateLimit-Limit', '100');
res.set('X-RateLimit-Remaining', String(remaining));
res.set('X-RateLimit-Reset', resetAt.toISOString());
if (!allowed) {
return res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: Math.ceil((resetAt - Date.now()) / 1000),
});
}
next();
}
Always return X-RateLimit-* headers. Good API clients (like the ones we build at Pillai Infotech) use these to implement exponential backoff automatically. Bad actors ignore them — which is fine, because the server enforces limits regardless.
6. CORS — Stop Misconfiguring It
CORS misconfigurations are in nearly every API audit we do. The most dangerous: reflecting the Origin header back as Access-Control-Allow-Origin — effectively disabling CORS entirely.
import cors from 'cors';
// ❌ DANGEROUS — allows any origin
app.use(cors({ origin: true, credentials: true }));
// ❌ DANGEROUS — reflects Origin header (same as above)
app.use(cors({
origin: (origin, callback) => callback(null, origin),
credentials: true,
}));
// ✅ SAFE — explicit allowlist
const ALLOWED_ORIGINS = [
'https://pillaiinfotech.com',
'https://app.pillaiinfotech.com',
'https://cmdcenter.pillaiinfotech.com',
];
if (process.env.NODE_ENV === 'development') {
ALLOWED_ORIGINS.push('http://localhost:3000');
ALLOWED_ORIGINS.push('http://localhost:8888');
}
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, Postman)
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) {
return callback(null, true);
}
callback(new Error('Blocked by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
maxAge: 86400, // Cache preflight for 24h
}));
Quick CORS checklist: Never use Access-Control-Allow-Origin: * with credentials. Never reflect the Origin header. Always specify exact allowed methods and headers. Set maxAge to reduce preflight requests. And remember — CORS only protects browsers. API-to-API calls bypass CORS entirely, so it's not a replacement for authentication.
7. Logging, Monitoring, and Incident Detection
You can't protect what you can't see. Security logging needs to answer: Who did what, to which resource, when, and from where?
What to Log
| Event | Log Level | Alert? | Data to Capture |
|---|---|---|---|
| Successful login | INFO | No | User ID, IP, user-agent, geo |
| Failed login | WARN | After 5 in 10min | Username attempted, IP, reason |
| Authorization denied | WARN | Yes — immediately | User, resource, required role, path |
| Rate limit hit | WARN | After 10 in 1min | Key, endpoint, count, IP |
| Input validation failure | INFO | Pattern-based | Field, violation, sanitized input |
| Admin action | INFO | Yes — always | Admin user, action, target, before/after |
| Data export / bulk read | INFO | If >1000 records | User, query, record count, filters |
What to never log: passwords, tokens, API keys, credit card numbers, SSNs, or any PII that isn't needed for investigation. If you log request bodies, redact sensitive fields first.
For more on building observability into your stack, see our monitoring and observability guide.
8. API Gateway Security Patterns
An API gateway centralizes cross-cutting security concerns. Instead of implementing auth, rate limiting, and logging in every service, do it once at the edge.
| Gateway | Type | Best For | Security Features |
|---|---|---|---|
| Kong | Open source | Self-hosted, plugin ecosystem | JWT, OAuth, rate limiting, IP restriction, bot detection |
| AWS API Gateway | Managed | AWS-native architectures | IAM auth, Lambda authorizers, WAF integration, throttling |
| Cloudflare API Shield | Edge | DDoS protection, global CDN | mTLS, schema validation, anomaly detection, bot management |
| Nginx + Lua | Self-hosted | Lightweight, full control | Custom rate limiting, JWT validation, header injection, IP filtering |
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
server {
listen 443 ssl http2;
server_name api.pillaiinfotech.com;
# TLS 1.2+ only — no legacy protocols
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Hide server version
server_tokens off;
# Request size limits
client_max_body_size 10m;
client_body_buffer_size 128k;
location /api/v1/auth/login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://backend;
}
location /api/v1/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Request-ID $request_id;
}
# Block common attack paths
location ~ /\.(git|env|htaccess) {
return 404;
}
}
9. The API Security Checklist
We use this checklist before every API goes to production. Not every item applies to every API, but every item should be consciously decided on.
| Category | Check | Priority |
|---|---|---|
| Authentication | Every endpoint requires authentication (unless explicitly public) | Critical |
| Tokens expire in ≤15 minutes, refresh tokens stored server-side | Critical | |
| JWT algorithm whitelisted, issuer and audience validated | High | |
| Authorization | Object-level authorization on every resource endpoint | Critical |
| Function-level authorization — admin endpoints require admin role | Critical | |
| Input | Schema validation on all request bodies (Zod, Joi, JSON Schema) | Critical |
| Query parameters validated and typed (no raw string to SQL) | Critical | |
| File uploads: type whitelist, size limit, virus scan | High | |
| Transport | TLS 1.2+ everywhere, HSTS enabled, no mixed content | Critical |
| CORS configured with explicit origin allowlist | High | |
| Rate Limiting | Auth endpoints: ≤5 req/min per IP | Critical |
| Pagination enforced with max page size (e.g., 100) | High | |
| Monitoring | Security events logged with user, IP, action, resource | High |
| Alerts on auth failures, authorization denials, rate limit spikes | High |
We've built this checklist into our CI pipeline. A pre-deploy script checks for common API security anti-patterns: routes without authentication middleware, direct request body to SQL (potential injection), and Access-Control-Allow-Origin: * in config files. It's not a full security audit — but it catches the "forgot to add the auth middleware" class of bugs before they reach production.
10. Frequently Asked Questions
Should I use API keys or OAuth 2.0?
API keys for server-to-server communication where both parties are under your control. OAuth 2.0 with PKCE for any user-facing application or third-party integration. Never expose API keys in frontend JavaScript — they'll end up in browser dev tools, GitHub search results, and Shodan within hours.
How do I protect against DDoS attacks on my API?
Layer your defenses: CDN/WAF at the edge (Cloudflare, AWS Shield), rate limiting at the API gateway, and resource quotas in your application. For critical APIs, add proof-of-work challenges or CAPTCHA on suspicious traffic patterns. No single layer stops all DDoS — it's about making attacks expensive relative to legitimate traffic.
Is GraphQL more secure than REST?
Neither is inherently more secure — the same auth, authorization, and validation principles apply. GraphQL adds unique attack surfaces: query complexity attacks (deeply nested queries), introspection exposure in production, and batching abuse. Disable introspection in production, enforce query depth limits (typically 10-15), and use persisted queries. See our GraphQL vs REST comparison for architectural trade-offs.
How often should I rotate API keys?
Every 90 days minimum, immediately if a key might be compromised. Design for rotation from the start: support multiple active keys per client (old key + new key), implement key expiry dates, and automate the rotation process. We've seen teams that can't rotate keys because their systems hardcode them everywhere — that's a design failure, not a security choice.
What's the best way to handle API versioning securely?
URL path versioning (/api/v2/) is simplest and most visible. The security concern is sunset: old API versions accumulate vulnerabilities while attention focuses on the latest version. Set deprecation dates, monitor old version traffic, and decommission aggressively. If /api/v1/ has a known vulnerability and 2% of traffic, shut it down and force migration.
Pillai Infotech LLP
We build secure APIs from day one — authentication architecture, input validation, rate limiting, and zero-trust security patterns. Let's secure your API.