Tutorial

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.

Security 14 min read
Security Layers Covered
  • 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

Before Deploying to Production
  • ✓ 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.