Securing Your API: Authentication, Authorization, and Rate Limiting
Complete guide to API security with JWT, OAuth 2.0, and production-ready examples. CSRF protection, rate limiting, and security headers explained.
- JWT authentication with refresh tokens
- OAuth 2.0 implementation for third-party auth
- Role-based access control (RBAC)
- Rate limiting strategies (token bucket, sliding window)
- CSRF and XSS protection
- Security headers and HTTPS enforcement
The Security Mindset
API security isn't a single feature you bolt on at the end—it's a series of defensive layers that work together to protect your application and users. A security breach can destroy years of trust in minutes.
I learned this lesson when a client's API was compromised due to weak authentication. Attackers exploited a JWT implementation flaw to access thousands of user accounts. The incident cost them millions in damages and regulatory fines.
This article covers the essential security practices every API should implement, with production-ready code examples.
Layer 1: Authentication with JWT
Authentication verifies who the user is. JSON Web Tokens (JWT) are the most common approach for stateless API authentication.
Secure JWT Implementation
// lib/auth.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
interface TokenPayload {
userId: string;
email: string;
role: string;
}
export class AuthService {
private readonly JWT_SECRET = process.env.JWT_SECRET!;
private readonly REFRESH_SECRET = process.env.REFRESH_SECRET!;
private readonly ACCESS_TOKEN_EXPIRY = '15m';
private readonly REFRESH_TOKEN_EXPIRY = '7d';
async hashPassword(password: string): Promise<string> {
// Use bcrypt with appropriate cost factor (10-12)
return bcrypt.hash(password, 12);
}
async verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, this.JWT_SECRET, {
expiresIn: this.ACCESS_TOKEN_EXPIRY,
algorithm: 'HS256',
issuer: 'your-app-name',
audience: 'your-app-api',
});
}
generateRefreshToken(userId: string): string {
return jwt.sign({ userId }, this.REFRESH_SECRET, {
expiresIn: this.REFRESH_TOKEN_EXPIRY,
algorithm: 'HS256',
});
}
verifyAccessToken(token: string): TokenPayload {
try {
return jwt.verify(token, this.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'your-app-name',
audience: 'your-app-api',
}) as TokenPayload;
} catch (error) {
throw new Error('Invalid or expired token');
}
}
verifyRefreshToken(token: string): { userId: string } {
try {
return jwt.verify(token, this.REFRESH_SECRET, {
algorithms: ['HS256'],
}) as { userId: string };
} catch (error) {
throw new Error('Invalid refresh token');
}
}
}
Authentication Middleware
// middleware/auth.ts
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/lib/auth';
const authService = new AuthService();
export async function authMiddleware(req: NextRequest) {
try {
// Extract token from Authorization header
const authHeader = req.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Missing or invalid authorization header' },
{ status: 401 }
);
}
const token = authHeader.substring(7);
const payload = authService.verifyAccessToken(token);
// Attach user info to request
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-user-id', payload.userId);
requestHeaders.set('x-user-role', payload.role);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
} catch (error) {
return NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
);
}
}
// Usage in API route
export async function GET(req: NextRequest) {
const userId = req.headers.get('x-user-id');
const role = req.headers.get('x-user-role');
// User is authenticated
return NextResponse.json({ userId, role });
}
Token Refresh Flow
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/lib/auth';
import { db } from '@/lib/db';
const authService = new AuthService();
export async function POST(req: NextRequest) {
try {
const { refreshToken } = await req.json();
// Verify refresh token
const { userId } = authService.verifyRefreshToken(refreshToken);
// Check if token is revoked (stored in Redis/database)
const isRevoked = await db.refreshToken.findFirst({
where: { token: refreshToken, revoked: true },
});
if (isRevoked) {
return NextResponse.json(
{ error: 'Token has been revoked' },
{ status: 401 }
);
}
// Fetch user data
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, email: true, role: true },
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Generate new tokens
const newAccessToken = authService.generateAccessToken({
userId: user.id,
email: user.email,
role: user.role,
});
const newRefreshToken = authService.generateRefreshToken(user.id);
// Optionally: Revoke old refresh token
await db.refreshToken.update({
where: { token: refreshToken },
data: { revoked: true },
});
// Store new refresh token
await db.refreshToken.create({
data: {
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return NextResponse.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
} catch (error) {
return NextResponse.json(
{ error: 'Invalid refresh token' },
{ status: 401 }
);
}
}
Layer 2: Authorization (RBAC)
Authorization determines what authenticated users can do. Role-Based Access Control (RBAC) is a scalable approach.
RBAC Implementation
// lib/rbac.ts
type Permission =
| 'users:read'
| 'users:write'
| 'users:delete'
| 'posts:read'
| 'posts:write'
| 'posts:delete'
| 'admin:access';
type Role = 'user' | 'moderator' | 'admin';
const rolePermissions: Record<Role, Permission[]> = {
user: ['posts:read', 'posts:write'],
moderator: ['posts:read', 'posts:write', 'posts:delete', 'users:read'],
admin: [
'users:read',
'users:write',
'users:delete',
'posts:read',
'posts:write',
'posts:delete',
'admin:access',
],
};
export class RBACService {
hasPermission(role: Role, permission: Permission): boolean {
return rolePermissions[role]?.includes(permission) ?? false;
}
hasAnyPermission(role: Role, permissions: Permission[]): boolean {
return permissions.some((p) => this.hasPermission(role, p));
}
hasAllPermissions(role: Role, permissions: Permission[]): boolean {
return permissions.every((p) => this.hasPermission(role, p));
}
}
// Authorization middleware
export function requirePermission(...permissions: Permission[]) {
return async (req: NextRequest) => {
const role = req.headers.get('x-user-role') as Role;
if (!role) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const rbac = new RBACService();
const hasPermission = rbac.hasAllPermissions(role, permissions);
if (!hasPermission) {
return NextResponse.json(
{ error: 'Insufficient permissions' },
{ status: 403 }
);
}
return NextResponse.next();
};
}
// Usage in API route
export const GET = [
authMiddleware,
requirePermission('users:read'),
async (req: NextRequest) => {
// User has users:read permission
const users = await db.user.findMany();
return NextResponse.json(users);
},
];
Layer 3: Rate Limiting
Rate limiting prevents abuse by restricting the number of requests a client can make.
Token Bucket Algorithm
// lib/rate-limiter.ts
import Redis from 'ioredis';
export class RateLimiter {
private redis: Redis;
constructor() {
this.redis = new Redis(process.env.REDIS_URL!);
}
async checkLimit(
identifier: string,
maxRequests: number,
windowMs: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const key = `rate-limit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
// Use Redis sorted set to track requests
const multi = this.redis.multi();
// Remove old requests outside window
multi.zremrangebyscore(key, 0, windowStart);
// Count requests in current window
multi.zcard(key);
// Add current request
multi.zadd(key, now, `${now}-${Math.random()}`);
// Set expiry
multi.expire(key, Math.ceil(windowMs / 1000));
const results = await multi.exec();
const count = results?.[1]?.[1] as number;
const allowed = count < maxRequests;
const remaining = Math.max(0, maxRequests - count - 1);
const resetAt = now + windowMs;
return { allowed, remaining, resetAt };
}
async resetLimit(identifier: string): Promise<void> {
await this.redis.del(`rate-limit:${identifier}`);
}
}
// Rate limiting middleware
export async function rateLimitMiddleware(
req: NextRequest,
maxRequests = 100,
windowMs = 60000 // 1 minute
) {
const limiter = new RateLimiter();
// Use IP address or user ID as identifier
const userId = req.headers.get('x-user-id');
const ip = req.headers.get('x-forwarded-for') || req.ip || 'unknown';
const identifier = userId || ip;
const { allowed, remaining, resetAt } = await limiter.checkLimit(
identifier,
maxRequests,
windowMs
);
const response = allowed
? NextResponse.next()
: NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
// Add rate limit headers
response.headers.set('X-RateLimit-Limit', maxRequests.toString());
response.headers.set('X-RateLimit-Remaining', remaining.toString());
response.headers.set('X-RateLimit-Reset', new Date(resetAt).toISOString());
return response;
}
Advanced: Tiered Rate Limiting
// Different limits based on user tier
const RATE_LIMITS = {
free: { requests: 100, window: 60000 },
pro: { requests: 1000, window: 60000 },
enterprise: { requests: 10000, window: 60000 },
};
export async function tieredRateLimitMiddleware(req: NextRequest) {
const userTier = req.headers.get('x-user-tier') as keyof typeof RATE_LIMITS;
const limits = RATE_LIMITS[userTier] || RATE_LIMITS.free;
return rateLimitMiddleware(req, limits.requests, limits.window);
}
Layer 4: CSRF Protection
Cross-Site Request Forgery (CSRF) tricks authenticated users into executing unwanted actions.
// lib/csrf.ts
import crypto from 'crypto';
export class CSRFService {
private readonly SECRET = process.env.CSRF_SECRET!;
generateToken(sessionId: string): string {
const timestamp = Date.now().toString();
const hash = crypto
.createHmac('sha256', this.SECRET)
.update(`${sessionId}-${timestamp}`)
.digest('hex');
return `${timestamp}.${hash}`;
}
verifyToken(token: string, sessionId: string): boolean {
try {
const [timestamp, hash] = token.split('.');
const expectedHash = crypto
.createHmac('sha256', this.SECRET)
.update(`${sessionId}-${timestamp}`)
.digest('hex');
// Check token age (e.g., 1 hour)
const tokenAge = Date.now() - parseInt(timestamp);
const maxAge = 60 * 60 * 1000; // 1 hour
return hash === expectedHash && tokenAge < maxAge;
} catch {
return false;
}
}
}
// Middleware
export async function csrfMiddleware(req: NextRequest) {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const csrfToken = req.headers.get('x-csrf-token');
const sessionId = req.cookies.get('session-id')?.value;
if (!csrfToken || !sessionId) {
return NextResponse.json(
{ error: 'Missing CSRF token' },
{ status: 403 }
);
}
const csrf = new CSRFService();
const isValid = csrf.verifyToken(csrfToken, sessionId);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid CSRF token' },
{ status: 403 }
);
}
}
return NextResponse.next();
}
Layer 5: Security Headers
HTTP security headers add an extra layer of protection against common attacks.
// middleware.ts
export function securityHeadersMiddleware(response: NextResponse) {
// Prevent clickjacking
response.headers.set('X-Frame-Options', 'DENY');
// Enable XSS protection
response.headers.set('X-XSS-Protection', '1; mode=block');
// Prevent MIME type sniffing
response.headers.set('X-Content-Type-Options', 'nosniff');
// Referrer policy
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"
);
// HTTPS enforcement
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
// Permissions policy
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
return response;
}
Layer 6: Input Validation
Never trust user input. Validate and sanitize everything.
// lib/validation.ts
import { z } from 'zod';
// Define schemas
export const createUserSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(100)
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[a-z]/, 'Must contain lowercase')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character'),
name: z.string().min(2).max(100).trim(),
});
// Validation middleware
export async function validateBody<T>(
req: NextRequest,
schema: z.ZodSchema<T>
): Promise<NextResponse | T> {
try {
const body = await req.json();
const validated = schema.parse(body);
return validated;
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Invalid request body' },
{ status: 400 }
);
}
}
// Usage
export async function POST(req: NextRequest) {
const validated = await validateBody(req, createUserSchema);
if (validated instanceof NextResponse) return validated;
// validated is type-safe
const { email, password, name } = validated;
// ... create user
}
Production Security Checklist
- ✓ Use HTTPS everywhere (enforce with HSTS headers)
- ✓ Store secrets in environment variables, never in code
- ✓ Implement JWT with short expiry times and refresh tokens
- ✓ Add rate limiting on all public endpoints
- ✓ Validate and sanitize all user inputs
- ✓ Enable CORS only for trusted origins
- ✓ Set secure cookie flags (HttpOnly, Secure, SameSite)
- ✓ Add security headers (CSP, X-Frame-Options, etc.)
- ✓ Log security events and monitor for anomalies
- ✓ Keep dependencies updated (use Dependabot)
- ✓ Conduct security audits and penetration testing
Key Takeaways
Security is layered. No single measure is perfect. Defense in depth means multiple layers working together.
Authentication verifies identity; authorization controls access. Don't confuse the two. Both are essential.
Rate limiting is non-negotiable. Without it, you're vulnerable to brute force attacks and DDoS.
Validate everything. User input is the primary attack vector. Never trust it.
Security is ongoing. Stay informed about new vulnerabilities and update dependencies regularly.