REST API Security: What Every Developer Must Know (2026)
Your API is only as secure as its weakest endpoint. Here's what you need to know to protect it.
The Threat Model
Who attacks your API?
→ Bots scanning for vulnerabilities (automated, high volume)
→ Scrapers stealing your data
→ Attackers probing for injection points
→ Malicious users exploiting business logic
→ Anyone who reads your public docs and finds gaps
What they want:
→ User data (PII, credentials, payment info)
→ Free access to paid features
→ Your server as a botnet node
→ Data they can sell or exploit
Good news: Most attacks target KNOWN vulnerability patterns.
Fix the basics → eliminate 90% of risk.
1. Authentication Done Right
// ❌ Bad: Sending credentials in URL or query params
// GET /api/users?email=user@domain.com&password=secret
// These get logged in access logs, browser history, proxy logs!
// ❌ Bad: Basic Auth over non-HTTPS
// Base64 encoding is NOT encryption (it's trivially reversible)
// ✅ Good: Bearer tokens in Authorization header
GET /api/users HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
// Token best practices:
const tokenConfig = {
// Access tokens: short-lived (15-30 min)
accessTokenTTL: 15 * 60 * 1000,
// Refresh tokens: longer-lived (7-30 days), stored securely
refreshTokenTTL: 7 * 24 * 60 * 60 * 1000,
// Always include expiry check
validateExpiry: true,
// Include issued-at time for clock drift tolerance
leeway: 60, // Accept tokens up to 60 seconds old/expired
// Token should include:
payload: {
sub: "user_123", // Subject (user ID)
iat: Date.now(), // Issued at
exp: Date.now() + 900, // Expiration
jti: crypto.randomUUID(), // Unique ID (for revocation)
role: "user", // For authorization
}
};
// Validate token on EVERY request
function authenticate(req) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) throw new AuthError('Missing token');
const token = header.slice(7);
try {
const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
// Check if token is revoked (for logout/blacklist)
if (await isRevoked(payload.jti)) throw new AuthError('Token revoked');
return payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new AuthError('Token expired', { code: 'TOKEN_EXPIRED' });
}
throw new AuthError('Invalid token');
}
}
2. Input Validation: Your First Line of Defense
// NEVER trust client input. Ever.
// Layer 1: Type validation before anything else
function validateInput(data, schema) {
const errors = [];
for (const [field, rules] of Object.entries(schema)) {
const value = data[field];
if (rules.required && (value === undefined || value === null || value === '')) {
errors.push({ field, issue: `${field} is required` });
continue;
}
if (value === undefined && !rules.required) continue; // Skip optional
// Type checks
if (rules.type === 'string') {
if (typeof value !== 'string') errors.push({ field, issue: 'Must be string' });
else if (rules.maxLength && value.length > rules.maxLength)
errors.push({ field, issue: `Max ${rules.maxLength} chars` });
else if (rules.pattern && !rules.pattern.test(value))
errors.push({ field, issue: 'Invalid format' });
else if (rules.enum && !rules.enum.includes(value))
errors.push({ field, issue: `Must be one of: ${rules.enum.join(', ')}` });
}
if (rules.type === 'number') {
const num = Number(value);
if (isNaN(num)) errors.push({ field, issue: 'Must be number' });
else if (rules.min !== undefined && num < rules.min)
errors.push({ field, issue: `Minimum is ${rules.min}` });
else if (rules.max !== undefined && num > rules.max)
errors.push({ field, issue: `Maximum is ${rules.max}` });
}
if (rules.type === 'array') {
if (!Array.isArray(value)) errors.push({ field, issue: 'Must be array' });
else if (rules.maxItems && value.length > rules.maxItems)
errors.push({ field, issue: `Max ${rules.maxItems} items` });
}
if (rules.type === 'object' && typeof value !== 'object')
errors.push({ field, issue: 'Must be object' });
}
return errors;
}
// Layer 2: Sanitization (clean what passes through)
function sanitize(str) {
return str
.replace(/<script\b[^<]*(?:<\/script>)?>/gi, '') // Remove script tags
.replace(/on\w+\s*=/gi, '') // Remove event handlers
.replace(/javascript:/gi, ''); // Remove JS protocol
}
// Layer 3: Length limits (prevent DoS via huge payloads)
app.use(express.json({ limit: '1mb' })); // Body size limit
// Practical example: User registration validation
const registerSchema = {
email: {
type: 'string',
required: true,
maxLength: 254,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
},
password: {
type: 'string',
required: true,
minLength: 8,
maxLength: 128,
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
},
name: {
type: 'string',
required: true,
minLength: 1,
maxLength: 100,
},
role: {
type: 'string',
required: false,
enum: ['user', 'creator'],
},
};
3. SQL Injection Prevention
-- ❌ VULNERABLE: String concatenation
SELECT * FROM users WHERE email = '$email';
-- Attacker sends: admin' OR '1'='1' --
-- Result: Returns ALL users (bypasses auth!)
-- Or worse: '; DROP TABLE users; --
// ✅ ALWAYS use parameterized queries
// With raw driver:
db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
db.prepare('SELECT * FROM products WHERE category = ? AND price < ?')
.all(category, maxPrice);
// With ORM (Sequelize):
User.findByPk(id); // Safe by default
User.findOne({ where: { email } }); // Parameterized automatically
// With Knex:
knex('users').where({ id: userId }).first(); // Safe
// Even with parameterized queries, validate first:
if (!/^[a-f0-9]{24}$/.test(id)) {
res.status(400).json({ error: 'Invalid ID format' });
return;
}
// ⚠️ Dangerous: Dynamic table/column names can't be parameterized!
// If you MUST use dynamic identifiers:
const allowedTables = ['users', 'products', 'orders'];
if (!allowedTables.includes(table)) throw new Error('Invalid table');
const safeColumn = column.replace(/[^a-z_]/gi, ''); // Strip everything except letters/underscore
4. XSS Prevention for APIs
// API XSS is different from web page XSS.
// The goal: prevent script injection that affects API consumers.
// Rule 1: Set correct Content-Type
app.get('/api/data', (req, res) => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.json({ data: result }); // Browser won't render JSON as HTML
});
// Rule 2: Sanitize user content that will be displayed
import DOMPurify from 'dompurify'; // Server-side HTML sanitization
app.post('/api/comments', (req, res) => {
const sanitizedContent = DOMPurify.sanitize(req.body.content);
// Stores clean HTML, safe to render later
});
// Rule 3: If you MUST return HTML, set CSP headers
res.setHeader('Content-Security-Policy',
"default-src 'none'; script-src 'none'; style-src 'self'"
);
// Rule 4: Never reflect user input unsanitized
// ❌ Bad
app.get('/api/search', (req, res) => {
res.send(`Results for: ${req.query.q}`); // Script injection!
});
// ✅ Good
app.get('/api/search', (req, res) => {
res.json({ results: [...], query: escapeHtml(req.query.q) });
});
5. Rate Limiting & Abuse Prevention
// Multi-layer rate limiting:
// Layer 1: Global (protects entire API)
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000, // 1000 requests per 15 min per IP
standardHeaders: true,
}));
// Layer 2: Per-endpoint (stricter for sensitive endpoints)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Only 10 login attempts per 15 min
skipSuccessfulRequests: true,
keyGenerator: (req) => req.ip, // Rate limit by IP
});
// Layer 3: Per-user (after auth)
const userApiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30, // 30 requests per minute per user
keyGenerator: (req) => req.user.id,
});
// Layer 4: Slow down brute force progressively
const slowDown = (maxAttempts) => {
const attempts = new Map();
return (req, res, next) => {
const key = req.ip;
const count = (attempts.get(key) || 0) + 1;
attempts.set(key, count);
// Exponential backoff delay
if (count > maxAttempts) {
const delayMs = Math.pow(2, count - maxAttempts) * 1000;
return setTimeout(() => next(), delayMs);
}
next();
};
};
// Response headers (always include!)
app.use((req, res, next) => {
res.setHeader('X-RateLimit-Limit', '100');
res.setHeader('X-RateLimit-Remaining', '95');
res.setHeader('X-RateLimit-Reset', new Date(Date.now() + 900000).toISOString());
next();
});
6. CORS Configuration
// ❌ DANGEROUS: Allow everything
cors({ origin: '*' }) // Any website can call your API!
// ✅ Secure: Explicit allowlist
app.use(cors({
origin: function (origin, callback) {
const allowedOrigins = [
'https://myapp.com',
'https://www.myapp.com',
'https://staging.myapp.com',
];
// Allow in development
if (process.env.NODE_ENV === 'development') {
return callback(null, true);
}
// No origin header (mobile apps, curl, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true, // Allow cookies/auth headers
maxAge: 86400, // Cache preflight for 24 hours
optionsSuccessStatus: 200, // Older browsers expect 200, not 204
}));
7. Security Headers Checklist
import helmet from 'helmet';
app.use(helmet({
// Prevent clickjacking
frameguard: { action: 'deny' }, // Or 'sameorigin' if you embed in iframes
// Prevent MIME sniffing
noSniff: true,
// XSS protection (legacy but still useful for older browsers)
xssFilter: true,
// Referrer policy
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
// HSTS (force HTTPS)
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true, // Submit to hstspreload.org
},
// DNS prefetching control
dnsPrefetchControl: true,
// Download protection (no automatic download)
noSniff: true,
// Cross-origin policy
crossOriginResourcePolicy: { policy: 'same-origin' },
crossOriginEmbedderPolicy: true,
}));
// Additional custom headers
app.use((req, res, next) => {
// Remove server fingerprinting
res.removeHeader('X-Powered-By'); // Don't reveal Express
// Add security hint
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
8. Sensitive Data Protection
// ❌ Never log sensitive data
console.log('Login attempt:', { email, password }); // Password in logs!
logger.info('User created:', userData); // May contain PII
// ✅ Safe logging
logger.info('Login attempt', { email, ip: req.ip }); // No password
logger.info('User created', { id: user.id }); // Minimal data
// ✅ Mask sensitive fields in responses
function sanitizeUser(user) {
const { passwordHash, mfaSecret, recoveryCodes, ...safe } = user;
return safe;
}
// ✅ Encrypt sensitive fields at rest
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
function encrypt(text, masterKey) {
const iv = randomBytes(IV_LENGTH);
const key = scryptSync(masterKey, 'salt', 32);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
return iv.toString('hex') + ':' + tag.toString('hex') + ':' + encrypted;
}
function decrypt(encryptedText, masterKey) {
const [ivHex, tagHex, encrypted] = encryptedText.split(':');
const key = scryptSync(masterKey, 'salt', 32);
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8');
}
// ✅ Use environment variables for secrets (never hardcode!)
const DB_ENCRYPTION_KEY = process.env.DB_ENCRYPTION_KEY;
if (!DB_ENCRYPTION_KEY || DB_ENCRYPTION_KEY.length < 32) {
console.error('FATAL: DB_ENCRYPTION_KEY must be >= 32 characters');
process.exit(1);
}
Pre-Deployment Security Checklist
□ All endpoints require authentication where appropriate?
□ Input validated AND sanitized on every endpoint?
□ Database uses parameterized queries everywhere?
□ Rate limiting on ALL endpoints (especially auth)?
□ CORS configured with explicit allowlist?
□ Security headers set (helmet.js)?
□ Secrets in environment variables (not in code)?
□ Error messages don't leak internals (stack traces, paths)?
□ HTTPS enforced everywhere?
□ Password hashing uses bcrypt/argon2 (not MD5/SHA)?
□ JWT tokens have short expiry + refresh token rotation?
□ File uploads validate type AND content (not just extension)?
□ SQL injection tested (with ' OR 1=1 -- patterns)?
□ XSS tested (with <script>alert(1)</script>)?
□ CSRF protection enabled (token or SameSite cookies)?
□ Dependencies audited (npm audit --json)?
□ Access controls checked (users can only access THEIR data)?
□ Admin endpoints have additional protection?
□ Logging doesn't capture passwords/tokens/PII?
□ API keys rotated regularly?
□ Web Application Firewall (WAF) in place?
Score yourself: Each ☑️ blocks an entire attack category.
What's the most important security practice you follow? What's one you always forget?
Follow @armorbreak for more practical developer guides.
Top comments (1)
Worth flagging the CORS config's false sense of security. The
if (!origin) return callback(null, true)pattern means any non-browser client — curl, Python requests, every bot framework, every attack tool — bypasses CORS entirely because they simply don't send an Origin header. CORS is a browser-cooperative mechanism enforced by the browser, not by the server. It protects the user's browser from being weaponized for cross-site requests, but it does nothing to protect the API from direct access. I've seen teams treat their CORS allowlist as if it were an access control layer, then get surprised when scrapers hit their endpoints without restriction. Authentication on every endpoint is the actual access control; CORS is a cross-origin policy for browsers specifically.