REST API Security Best Practices 2026: HTTPS, JWT, Rate Limiting & CORS
REST API security is foundational — every web and mobile application relies on APIs, and APIs are the primary attack surface in modern software. A single misconfigured endpoint can expose your entire user database. This guide covers the complete security stack for production REST APIs in 2026.
Start with a free baseline scan of your live API at ZeriFlow — it checks 80+ security controls including headers, TLS grade, and common endpoint exposures.
1. Enforce HTTPS for Every Request
All REST API traffic must use HTTPS. HTTP transmits credentials, tokens, and data in plaintext — any network observer can read and modify them.
At the web server (Nginx), redirect all HTTP to HTTPS:
server {
listen 80;
server_name api.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains; preload' always;
}Also enforce HTTPS in your application layer as a second line of defense (shown in framework-specific guides above).
Check your TLS grade with [ZeriFlow](https://zeriflow.com) — it tests for TLS 1.0/1.1 support, weak ciphers, certificate expiry, and HSTS configuration.
2. Authentication: JWT and OAuth 2.0
JWT best practices:
const jwt = require('jsonwebtoken');
// Signing — always use asymmetric keys (RS256/ES256) for production
const privateKey = process.env.JWT_PRIVATE_KEY;
function generateTokens(userId, role) {
const accessToken = jwt.sign(
{ sub: userId, role, type: 'access' },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m', // Short-lived
issuer: 'api.yourdomain.com',
audience: 'yourdomain.com',
}
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
privateKey,
{ algorithm: 'RS256', expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// Verification — always verify signature AND claims
const publicKey = process.env.JWT_PUBLIC_KEY;
function verifyAccessToken(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'api.yourdomain.com',
audience: 'yourdomain.com',
});
}Token storage rules: - Access tokens: memory only (never localStorage) - Refresh tokens: httpOnly, Secure, SameSite=Strict cookies - Never log tokens, never include them in URLs
Rotate refresh tokens on every use (refresh token rotation):
app.post('/auth/refresh', async (req, res) => {
const oldRefreshToken = req.cookies.refresh_token;
const payload = verifyRefreshToken(oldRefreshToken);
// Check token has not been revoked
const stored = await RefreshToken.findOne({ token: oldRefreshToken });
if (!stored || stored.revoked) {
// Possible token reuse attack — revoke all tokens for this user
await RefreshToken.revokeAllForUser(payload.sub);
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Revoke old, issue new
await stored.revoke();
const { accessToken, refreshToken } = generateTokens(payload.sub, payload.role);
await RefreshToken.create({ token: refreshToken, userId: payload.sub });
res.cookie('refresh_token', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 3600 * 1000,
});
res.json({ accessToken });
});3. Rate Limiting
Without rate limiting, your API is vulnerable to brute force, credential stuffing, and enumeration attacks.
const rateLimit = require('express-rate-limit');
const slowDown = require('express-slow-down');
// Progressive delay before hard limit
const speedLimiter = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 50, // Start slowing down after 50 requests
delayMs: (used, req) => (used - 50) * 200, // Add 200ms per request over limit
});
// Hard limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
// Strict limit for sensitive endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true,
handler: (req, res) => res.status(429).json({
error: 'Too many authentication attempts'
}),
});
app.use('/api/', speedLimiter, apiLimiter);
app.use('/api/auth/', authLimiter);
app.use('/api/password-reset/', authLimiter);Always return the Retry-After header so clients know when to retry:
const limiter = rateLimit({
handler: (req, res, next, options) => {
res.status(429)
.set('Retry-After', Math.ceil(options.windowMs / 1000))
.json({ error: 'Rate limit exceeded', retryAfter: options.windowMs / 1000 });
},
});4. Input Validation and Sanitization
Never trust client-supplied data. Validate every parameter — path, query, body, and header.
const { z } = require('zod');
const createOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
address: z.object({
street: z.string().max(200),
city: z.string().max(100),
country: z.string().length(2).toUpperCase(), // ISO 3166 alpha-2
}),
});
app.post('/orders', authenticate, async (req, res) => {
const result = createOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Invalid request body',
details: result.error.flatten().fieldErrors,
});
}
const { productId, quantity, address } = result.data;
// All fields are validated and typed
const order = await Order.create({ userId: req.user.id, productId, quantity, address });
res.status(201).json(order);
});For SQL queries, always use parameterized statements regardless of your ORM:
// Dangerous
const users = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`);
// Safe
const users = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);5. CORS Policy
Configure CORS to allow only your known client origins.
const cors = require('cors');
const corsOptions = {
origin: (origin, callback) => {
const allowed = ['https://app.yourdomain.com', 'https://yourdomain.com'];
if (!origin || allowed.includes(origin)) return callback(null, true);
callback(new Error(`Origin ${origin} not permitted`));
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'],
credentials: true,
maxAge: 86400,
};
app.use('/api/', cors(corsOptions));For internal APIs never accessed from browsers, disable CORS entirely by not including the header — the absence of an Access-Control-Allow-Origin header means browsers block cross-origin requests.
6. API Versioning and Safe Error Messages
Version your API to allow breaking security changes without disrupting clients:
// URL versioning (most visible, easiest to manage)
app.use('/api/v1/', v1Router);
app.use('/api/v2/', v2Router);
// Or header versioning
app.use((req, res, next) => {
req.apiVersion = req.headers['api-version'] || '1';
next();
});Never leak internal details in error responses:
// Dangerous
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message, // Leaks internal details
stack: err.stack, // NEVER do this in production
query: err.query, // Leaks SQL
});
});
// Safe
app.use((err, req, res, next) => {
// Log full details internally
console.error({ requestId: req.id, error: err.message, stack: err.stack });
// Return generic message to client
const status = err.statusCode || 500;
const publicMessage = status < 500 ? err.message : 'An internal error occurred';
res.status(status).json({
error: publicMessage,
requestId: req.id, // For support lookup
});
});For auth endpoints specifically, return identical error messages for 'user not found' and 'wrong password' — different messages enable user enumeration attacks.
FAQ
### Q: Should I use JWT or opaque tokens for REST APIs? A: JWTs are stateless and self-contained — good for microservices. Opaque tokens require a database lookup but can be revoked immediately. For most applications, use short-lived JWTs (15 min) with refresh token rotation, giving you near-immediate revocation on refresh.
### Q: How do I protect against IDOR (Insecure Direct Object Reference)?
A: Never use sequential IDs in public APIs. Use UUIDs or opaque identifiers, and always verify the authenticated user owns the resource before returning or modifying it: WHERE id = $1 AND user_id = $2.
### Q: What HTTP methods should I expose on a public REST API?
A: Only expose the methods you actually use. Configure your web server to return 405 for disallowed methods. Never expose TRACE (enables XST attacks) or OPTIONS beyond what CORS preflight requires.
### Q: How do I handle API authentication for mobile apps? A: Use OAuth 2.0 PKCE flow for mobile apps — it provides secure authorization without requiring a client secret to be stored on the device. Combine with certificate pinning to prevent MITM attacks.
### Q: Is API gateway-level security sufficient, or do I need application-level security too? A: Both are required. API gateways handle rate limiting, authentication, and TLS termination at the perimeter. Application-level validation, authorization, and business logic security must exist in the application itself — never rely solely on the gateway.
Conclusion
REST API security in 2026 is a stack of layers: TLS enforced at the transport layer, JWT or OAuth for authentication, rate limiting on every endpoint, input validation via schemas, strict CORS, versioned endpoints, and error responses that reveal nothing internal. Each layer independently limits the blast radius of a breach.
Verify your live API configuration with ZeriFlow's free scanner — it checks your TLS grade, security headers, CORS configuration, and common exposed endpoints in one scan, giving you a complete outside-in picture of your API's security posture.