REST API Security: What Every Developer Must Know (2026)
Your API works. Now make sure it doesn't become the next data breach headline.
The Threat Model
Who attacks your API?
→ Bots scraping your data
→ Attackers trying SQL injection
→ Someone brute-forcing passwords
→ Malicious users manipulating other users' data
→ Anyone intercepting traffic (MITM)
Security isn't about eliminating all risk.
It's about making attacks expensive enough that they're not worth it.
1. Authentication & Authorization
Never Roll Your Own Auth
// ❌ NEVER do this
function hashPassword(password) {
return crypto.createHash('md5').update(password).digest('hex');
}
// MD5 is broken. SHA-1 is broken. Don't hash passwords yourself.
// ✅ Use bcrypt, argon2, or scrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
async function hashPassword(password) {
return await bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// bcrypt is slow by design — that's the point!
// Makes brute-force attacks prohibitively expensive.
JWT Best Practices
const jwt = require('jsonwebtoken');
// Sign with strong secret + short expiry
function generateToken(user) {
return jwt.sign(
{
id: user.id,
role: user.role,
// Don't put sensitive data in JWT payload (it's base64 encoded!)
},
process.env.JWT_SECRET, // At least 32 random chars
{
expiresIn: '15m', // Short-lived access token
issuer: 'myapp.com',
audience: 'myapp-api',
}
);
}
// Verify properly
function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'myapp.com',
audience: 'myapp-api',
});
} catch (err) {
if (err.name === 'TokenExpiredError') throw new UnauthorizedError('Token expired');
if (err.name === 'JsonWebTokenError') throw new UnauthorizedError('Invalid token');
throw new UnauthorizedError('Authentication failed');
}
}
Middleware Pattern
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
function auth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid authorization header');
}
const token = header.split(' ')[1];
const payload = verifyToken(token);
req.user = payload; // Attach to request for downstream handlers
next();
}
// Role-based access
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
throw new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
}
next();
};
}
// Usage:
app.get('/api/admin', auth, requireRole('admin'), adminController.dashboard);
app.get('/api/users/me', auth, userController.getProfile);
2. Input Validation
Validate EVERYTHING from the Client
// ❌ Trusting client input
app.get('/api/users/:id', async (req, res) => {
const user = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`);
// SQL INJECTION! User sends "1; DROP TABLE users--"
});
// ✅ Parameterized queries + validation
const { validate } = require('./middleware/validate');
app.get('/api/users/:id',
validate({ id: [{ type: 'string', pattern: /^[a-f0-9]{24}$/ }] }), // MongoDB ObjectId format
auth,
async (req, res) => {
const user = await db.collection('users').findOne({
_id: new ObjectId(req.params.id) // Safe parameterized query
});
if (!user) throw new NotFoundError('User');
res.json(sanitizeUser(user));
}
);
// Validate body on write endpoints
app.post('/api/users',
validate({
email: [
{ required: true },
{ type: 'email' },
{ maxLength: 255 },
],
name: [
{ required: true },
{ minLength: 2 },
{ maxLength: 100 },
{ pattern: /^[a-zA-Z\s'-]+$/, message: 'Only letters, spaces, hyphens, apostrophes' },
],
password: [
{ required: true },
{ minLength: 8 },
{ custom: (val) => !val.includes(' ') ? null : 'Password cannot contain spaces' },
],
}),
userController.create
);
Sanitize Output (Don't Leak Data)
// Remove sensitive fields before sending to client
function sanitizeUser(user) {
const { passwordHash, mfaSecret, recoveryCodes, ...safe } = user;
return safe;
}
// Also sanitize error messages in production:
// ❌ "Duplicate entry 'user@example.com' for key 'email'" → reveals DB structure
// ✅ "A user with this email already exists"
3. Rate Limiting
// Simple in-memory rate limiter
const rateLimitMap = new Map();
function rateLimit(options = {}) {
const {
windowMs = 60_000, // 1 minute window
maxRequests = 100, // Max requests per window
keyGenerator = (req) => req.ip, // How to identify users
} = options;
return (req, res, next) => {
const key = keyGenerator(req);
const now = Date.now();
if (!rateLimitMap.has(key)) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
} else {
const record = rateLimitMap.get(key);
if (now > record.resetAt) {
// Window reset
record.count = 1;
record.resetAt = now + windowMs;
} else {
record.count++;
if (record.count > maxRequests) {
const retryAfterSec = Math.ceil((record.resetAt - now) / 1000);
res.set('Retry-After', retryAfterSec.toString());
res.set('X-RateLimit-Limit', maxRequests.toString());
res.set('X-RateLimit-Remaining', '0');
res.set('X-RateLimit-Reset', record.resetAt.toString());
throw new RateLimitError(retryAfterSec);
}
}
}
next();
};
}
// Apply globally
app.use(rateLimit({ windowMs: 60_000, maxRequests: 100 }));
// Stricter for auth endpoints (brute-force protection)
app.post('/api/auth/login',
rateLimit({ windowMs: 15 * 60_000, maxRequests: 10 }), // 10 attempts per 15 min
authController.login
);
app.post('/api/auth/register',
rateLimit({ windowMs: 60 * 60_000, maxRequests: 3 }), // 3 registrations per hour
authController.register
);
// Per-user rate limit for API keys
const apiRateLimit = rateLimit({
windowMs: 60_000,
maxRequests: 50,
keyGenerator: (req) => req.user?.id || req.ip,
});
4. HTTPS & Headers
// helmet.js sets security headers automatically
const helmet = require('helmet');
app.use(helmet());
// What helmet sets:
// X-Content-Type-Options: nosniff → Prevent MIME sniffing
// X-Frame-Options: SAMEORIGIN → Prevent clickjacking
// X-XSS-Protection: 1; mode=block → Basic XSS protection
// Referrer-Policy: strict-origin-when-cross-origin → Control referrer info
// Content-Security-Policy: default-src 'self' → Restrict resource loading
// Custom CSP for apps that need external resources
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'cdn.example.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'fonts.googleapis.com'],
fontSrc: ["'self'", 'fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'api.example.com'],
},
}));
// Force HTTPS in production
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
return res.redirect(301, `https://${req.header('host')}${req.url}`);
}
next();
});
}
5. CORS Configuration
const cors = require('cors');
// ❌ DANGEROUS: Allow everything
app.use(cors()); // Allows ALL origins!
// ✅ Tight configuration
app.use(cors({
origin: function (origin, callback) {
const allowedOrigins = [
'https://myapp.com',
'https://www.myapp.com',
'https://staging.myapp.com',
];
// Allow no origin for server-to-server (Postman, curl, etc.)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true, // Allow cookies/auth headers
maxAge: 86400, // Pre-flight cache for 24h
optionsSuccessStatus: 204, // No content for pre-flight
}));
6. Common Vulnerabilities & How to Fix Them
SQL Injection
-- ❌ String concatenation (vulnerable)
SELECT * FROM users WHERE id = '$userId'
-- ✅ Parameterized queries (safe)
SELECT * FROM users WHERE id = ?
-- Or: SELECT * FROM users WHERE id = $1
XSS (Cross-Site Scripting)
// ❌ Rendering raw HTML
res.send(`<div>${userInput}</div>`);
// ✅ Escape output
const escapeHtml = (str) => str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
res.send(`<div>${escapeHtml(userInput)}</div>`);
// Better: Use a template engine that auto-escapes (EJS, Pug, Handlebars)
// For APIs returning JSON: XSS risk is lower but still possible via JSONP
CSRF (Cross-Site Request Forgery)
// For cookie-based auth, use CSRF tokens:
const csrf = require('csurf');
app.use(csrf({ cookie: true }));
// Include token in forms and AJAX requests:
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// Client must send this token with every mutating request:
fetch('/api/update', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
// Note: If using JWT (not cookies), CSRF is less of a concern
IDOR (Insecure Direct Object Reference)
// ❌ Anyone can access any user's data
app.get('/api/orders/:id', auth, async (req, res) => {
const order = await Order.findById(req.params.id); // Any ID!
res.json(order);
});
// ✅ Enforce ownership
app.get('/api/orders/:id', auth, async (req, res) => {
const order = await Order.findOne({
_id: req.params.id,
userId: req.user.id, // MUST match authenticated user!
});
if (!order) throw new NotFoundError('Order');
res.json(order);
});
Mass Assignment
// ❌ Accept entire body blindly
app.put('/api/users/:id', async (req, res) => {
const user = await User.findByIdAndUpdate(req.params.id, req.body);
// Attacker sends { role: 'admin' } → instant admin!
});
// ✅ Whitelist allowed fields
const ALLOWED_UPDATES = ['name', 'email', 'avatar'];
app.put('/api/users/:id', async (req, res) => {
const updates = {};
for (const field of ALLOWED_UPDATES) {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
}
const user = await User.findByIdAndUpdate(req.params.id, updates);
res.json(user);
});
7. Logging & Monitoring
// Log security-relevant events
const securityLog = {
login_success: (req, user) => ({
event: 'auth.login.success',
userId: user.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
}),
login_failure: (req, reason) => ({
event: 'auth.login.failure',
ip: req.ip,
reason, // 'bad_password', 'account_locked', etc.
timestamp: new Date().toISOString(),
}),
permission_denied: (req, resource) => ({
event: 'auth.denied',
userId: req.user?.id,
ip: req.ip,
resource,
method: req.method,
path: req.path,
timestamp: new Date().toISOString(),
}),
rate_limit_exceeded: (req) => ({
event: 'security.rate_limit',
ip: req.ip,
path: req.path,
timestamp: new Date().toISOString(),
}),
};
// Send alerts for suspicious activity
function checkForAnomalies(logEntry) {
const ipFailures = countRecentFailures(logEntry.ip);
if (ipFailures >= 10) {
alertSecurityTeam({
level: 'warning',
type: 'brute_force_attempt',
ip: logEntry.ip,
failures: ipFailures,
});
}
}
Security Checklist
Before deploying any API:
□ All endpoints require authentication (except public ones)
□ Passwords hashed with bcrypt/argon2 (never plaintext)
□ JWT secrets are ≥ 32 chars and stored in env vars
□ Token expiry is short (≤ 15min for access tokens)
□ All inputs validated AND sanitized
□ Parameterized queries (never string concatenation)
□ Rate limiting on all endpoints (stricter on auth)
□ CORS configured to specific origins only
□ Security headers set (helmet.js)
□ HTTPS enforced in production
□ Ownership checks on all user-scoped resources
□ Field whitelisting on update endpoints
□ No sensitive data in error messages (production)
□ Security logging for auth events
□ Dependencies up to date (npm audit)
□ No secrets in code or git history (.env gitignored)
What's the most important security practice you follow?
Follow @armorbreak for more practical developer guides.
Top comments (0)