REST API Security Best Practices: Authentication, Input Validation, and CORS
A practical guide to securing REST APIs — covering authentication patterns, HTTPS enforcement, rate limiting, input validation, error handling, CORS configuration, and versioning strategies.
REST APIs are the backbone of modern web applications, mobile apps, and service integrations. They are also one of the most attacked surfaces in any organization's infrastructure. Unlike web interfaces where a browser enforces many security policies, APIs are directly callable by anyone with an HTTP client — making explicit security controls essential.
This guide covers the security fundamentals every production REST API must implement.
1. Authentication Patterns
Choosing the right authentication mechanism depends on your API's use case.
API Keys (Machine-to-Machine)
API keys are long, random strings used to authenticate server-to-server integrations. They are simple and effective when used correctly.
// Generating a cryptographically secure API key
import crypto from 'crypto';
function generateApiKey(prefix: string): string {
const key = crypto.randomBytes(32).toString('hex');
return `${prefix}_${key}`;
// Example: shss_a3f1b2c4d5e6...
}
// Validating an API key in middleware
async function validateApiKey(req: Request, res: Response, next: NextFunction) {
const key = req.headers['x-api-key'] as string | undefined;
if (!key) {
return res.status(401).json({ error: 'API key required' });
}
// Hash the key before DB lookup to protect stored keys
const hash = crypto.createHash('sha256').update(key).digest('hex');
const keyRecord = await ApiKey.findOne({ keyHash: hash, active: true });
if (!keyRecord) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.apiKeyRecord = keyRecord;
next();
}
Key rules for API keys:
- Generate with at least 256 bits of entropy.
- Store only the hash (SHA-256), never the plaintext.
- Use a prefix for easy identification in logs and source code scans.
- Support key rotation without downtime by allowing multiple active keys.
JWT (User-Facing APIs)
JWTs are appropriate for authenticating human users, particularly in stateless architectures. Store them in HTTP-only cookies rather than localStorage:
import jwt from 'jsonwebtoken';
import { Response } from 'express';
function issueToken(userId: string, role: string, res: Response) {
const token = jwt.sign(
{ userId, role },
process.env.JWT_SECRET,
{ expiresIn: '7d', algorithm: 'HS256' }
);
res.cookie('authToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
OAuth 2.0 / OIDC (Third-Party Authorization)
When your API needs to act on behalf of users from another system (GitHub, Google, Slack), use OAuth 2.0. Use a proven library rather than implementing the flow yourself:
npm install openid-client
2. Enforce HTTPS
Never serve an API over plain HTTP in production. Redirect HTTP to HTTPS at the load balancer or application level:
app.use((req, res, next) => {
if (
process.env.NODE_ENV === 'production' &&
req.headers['x-forwarded-proto'] !== 'https'
) {
return res.redirect(301, `https://${req.hostname}${req.originalUrl}`);
}
next();
});
Set HSTS to tell browsers to always use HTTPS, even on the first request after the redirect:
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
next();
});
3. Rate Limiting
Rate limiting protects against brute-force attacks, credential stuffing, scraping, and abuse. Apply different limits to different endpoints:
import rateLimit from 'express-rate-limit';
// Strict limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: { error: 'Too many login attempts. Please try again later.' },
standardHeaders: true, // Return RateLimit-* headers
legacyHeaders: false,
});
// General API limit
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
message: { error: 'Rate limit exceeded.' },
keyGenerator: (req) => req.headers['x-api-key'] as string || req.ip,
});
app.use('/api/auth/', authLimiter);
app.use('/api/', apiLimiter);
For distributed deployments, use Redis-backed rate limiting so limits are shared across instances:
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();
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
});
4. Input Validation and Sanitization
Every request parameter — path params, query strings, request body, headers — should be validated before use.
import { z } from 'zod';
const CreateProductSchema = z.object({
name: z.string().min(1).max(200).trim(),
price: z.number().positive().max(1_000_000),
category: z.enum(['electronics', 'clothing', 'food']),
description: z.string().max(2000).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
});
app.post('/api/products', async (req, res) => {
const result = CreateProductSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Invalid input',
details: result.error.flatten().fieldErrors,
});
}
const product = await Product.create({
...result.data,
userId: req.user.userId,
});
res.status(201).json({ data: product });
});
For path parameters, validate before using in database queries:
const UUIDSchema = z.string().uuid();
app.get('/api/products/:id', async (req, res) => {
const idResult = UUIDSchema.safeParse(req.params.id);
if (!idResult.success) {
return res.status(400).json({ error: 'Invalid product ID format' });
}
const product = await Product.findOne({ productId: idResult.data });
if (!product) return res.status(404).json({ error: 'Not found' });
res.json({ data: product });
});
5. Proper HTTP Methods and Idempotency
Use HTTP methods semantically and consistently:
| Method | Use Case | Idempotent | Safe |
|---|---|---|---|
| GET | Retrieve resource | Yes | Yes |
| POST | Create resource | No | No |
| PUT | Replace resource | Yes | No |
| PATCH | Partial update | No | No |
| DELETE | Remove resource | Yes | No |
Never use GET requests for state-changing operations. GET requests are cached, logged, and bookmarked — a GET /api/admin/delete-all-users?confirm=true is a disaster waiting to happen.
6. API Versioning
Version your API from day one. Breaking changes are inevitable, and versioning prevents you from forcing all clients to upgrade simultaneously:
// URL versioning (most common)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Header versioning (cleaner URLs)
app.use('/api', (req, res, next) => {
const version = req.headers['api-version'] ?? 'v1';
if (version === 'v2') {
return v2Handler(req, res, next);
}
return v1Handler(req, res, next);
});
Deprecate old versions with warning headers before removing them:
app.use('/api/v1', (req, res, next) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
res.setHeader('Link', '</api/v2>; rel="successor-version"');
next();
});
7. Error Message Exposure
Error responses are a common source of information disclosure. Internal errors, stack traces, and database error messages should never reach the client.
// Generic error handler for Express
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// Log the full error internally
logger.error('Unhandled error', {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
// Return a generic message to the client
const status = (err as any).status ?? 500;
const message = status < 500
? err.message // Client errors (4xx) can be descriptive
: 'An unexpected error occurred. Please try again.'; // Server errors must be generic
res.status(status).json({
error: message,
requestId: req.id, // Include a reference ID for support
});
});
Never expose:
- Stack traces (
err.stack) - SQL or NoSQL error messages (may reveal schema)
- Internal service names or IPs
- Library versions in error messages
8. CORS Configuration
Cross-Origin Resource Sharing headers tell browsers which origins can call your API. A misconfigured CORS policy is one of the most common API security issues.
import cors from 'cors';
const ALLOWED_ORIGINS = [
'https://app.yourdomain.com',
'https://yourdomain.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean) as string[];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, Postman)
if (!origin) return callback(null, true);
if (!ALLOWED_ORIGINS.includes(origin)) {
return callback(new Error(`Origin ${origin} not allowed`));
}
callback(null, true);
},
credentials: true, // Required if using cookies
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
maxAge: 600, // Cache preflight responses for 10 minutes
}));
Avoid origin: '*' for authenticated APIs — it allows any website to make requests to your API on behalf of your users.
Summary Checklist
| Control | Implementation |
|---|---|
| Authentication | API keys (machine-to-machine), JWT in httpOnly cookie (user-facing) |
| HTTPS | Redirect middleware + HSTS header |
| Rate limiting | express-rate-limit + Redis for distributed deployments |
| Input validation | Zod/Joi schema validation on every endpoint |
| HTTP methods | Semantic usage (no GET for mutations) |
| Versioning | URL versioning from day one |
| Error handling | Generic 500 messages; full errors logged server-side only |
| CORS | Explicit origin allowlist; avoid * for authenticated APIs |
REST API security is about removing the implicit trust that default configurations assume. By validating every input, authenticating every request, and sanitizing every error response, you build an API that is both robust and resilient to the attack patterns that target APIs daily.