REST API Security: Validation, Rate Limiting & Resilience

Production-grade patterns for securing REST APIs: request validation, distributed rate limiting with Redis, circuit breakers, and resilience strategies in Node.js.

0

Deploying a REST API without proper security layers means you’re betting that attackers won’t find you, or that your infrastructure can absorb the hit. Some teams get lucky. Most don’t. The upfront investment in hardening pays for itself the first time you avoid an outage or breach.

This article covers three interconnected security patterns that separate early-stage projects from enterprise-grade systems: strict request validation, distributed rate limiting, and resilience strategies that keep your API standing under real-world load and attack. We’ll work through Node.js implementations, trade-offs, and architectural considerations you’ll actually face when scaling.

Request Validation: The First Line of Defense

Most injection attacks, malformed payload exploits, and unexpected errors start with bad input. Validating requests at the edge of your API, before they touch your business logic or database, stops problems early.

JSON Schema is a declarative, standardized way to describe what a valid request looks like. Instead of writing imperative validation code scattered across your handlers, you define the shape once and reuse it.

import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv();
addFormats(ajv);

const userSchema = {
  type: 'object',
  properties: {
    email: { type: 'string', format: 'email' },
    username: { type: 'string', minLength: 3, maxLength: 20 },
    age: { type: 'integer', minimum: 18, maximum: 120 },
    tags: { type: 'array', items: { type: 'string' }, maxItems: 10 }
  },
  required: ['email', 'username'],
  additionalProperties: false
};

const validateUser = ajv.compile(userSchema);

export function validateRequest(req, res, next) {
  const valid = validateUser(req.body);
  if (!valid) {
    return res.status(400).json({
      error: 'Validation failed',
      details: validateUser.errors
    });
  }
  next();
}

Key benefits of this approach:

  • Schemas are versionable and testable independently of handler code
  • Error messages are standardized and can be localized
  • You catch type mismatches, unexpected fields, and constraint violations before they propagate
  • The schema doubles as API documentation

Apply this middleware to every endpoint that accepts user input. For endpoints that accept file uploads, add file size limits and MIME type checks at the HTTP middleware level using Express middleware like multer with size and filter options.

Distributed Rate Limiting with Token Bucket

Rate limiting prevents both accidental overload and malicious DDoS attacks. At scale, you need distributed rate limiting so that limits are enforced across all your API instances, not per-server.

The token bucket algorithm is simple and elegant. Each client gets a bucket that holds a fixed number of tokens. Each request consumes one token. Tokens refill at a constant rate. When the bucket is empty, requests are rejected. This allows short bursts while still enforcing an average rate.

Redis is ideal for this because it’s fast, atomic, and distributed. Here’s a practical implementation:

import redis from 'redis';

const client = redis.createClient({
  host: 'localhost',
  port: 6379
});

await client.connect();

export async function rateLimitMiddleware(req, res, next) {
  const clientId = req.ip || req.connection.remoteAddress;
  const key = `rate-limit:${clientId}`;
  const limit = 100; // requests per window
  const window = 60; // seconds
  const tokens = 1; // tokens per request

  try {
    const current = await client.incr(key);
    
    if (current === 1) {
      // First request in this window, set expiration
      await client.expire(key, window);
    }
    
    if (current > limit) {
      const ttl = await client.ttl(key);
      res.set('Retry-After', ttl);
      return res.status(429).json({
        error: 'Too many requests',
        retryAfter: ttl
      });
    }
    
    res.set('X-RateLimit-Limit', limit);
    res.set('X-RateLimit-Remaining', limit - current);
    res.set('X-RateLimit-Reset', Math.floor(Date.now() / 1000) + window);
    
    next();
  } catch (err) {
    // On Redis failure, fail open or closed depending on your risk tolerance
    // Fail open: allow the request
    // Fail closed: reject the request
    console.error('Rate limit check failed:', err);
    next(); // Fail open
  }
}

This approach scales linearly: add more API instances and they all check the same Redis counter. The overhead is minimal because Redis operations are sub-millisecond.

For more sophisticated scenarios, you might need per-endpoint limits, per-user limits after authentication, or tiered limits based on subscription level. Extend the key structure accordingly:

// Per-endpoint limit
const key = `rate-limit:${clientId}:${req.path}`;

// Per-user limit (after authentication)
const key = `rate-limit:user:${req.user.id}`;

// Tiered by subscription
const tier = req.user.subscription || 'free';
const limits = { free: 100, pro: 1000, enterprise: 10000 };
const limit = limits[tier];

Monitor your Redis instance to ensure it’s not becoming a bottleneck. For very high throughput, consider local caches with periodic Redis sync, but this trades consistency for speed.

Building Resilience: Circuit Breakers and Retries

Your API rarely exists in isolation. It calls databases, external services, message queues, and downstream APIs. When one of these dependencies fails, your entire API can cascade into failure if you’re not careful.

The circuit breaker pattern prevents cascading failures. A circuit breaker wraps calls to external services and can exist in three states:

  • Closed: normal operation, requests flow through
  • Open: the service is failing, requests are rejected immediately without calling the service
  • Half-open: a test request is allowed to probe if the service has recovered

Here’s a lightweight implementation:

class CircuitBreaker {
  constructor(fn, options = {}) {
    this.fn = fn;
    this.failureThreshold = options.failureThreshold || 5;
    this.successThreshold = options.successThreshold || 2;
    this.timeout = options.timeout || 60000; // ms
    this.state = 'CLOSED';
    this.failureCount = 0;
    this.successCount = 0;
    this.nextAttempt = Date.now();
  }

  async execute(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit breaker is OPEN');
      }
      this.state = 'HALF_OPEN';
    }

    try {
      const result = await this.fn(...args);
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    this.failureCount = 0;
    if (this.state === 'HALF_OPEN') {
      this.successCount++;
      if (this.successCount >= this.successThreshold) {
        this.state = 'CLOSED';
        this.successCount = 0;
      }
    }
  }

  onFailure() {
    this.failureCount++;
    this.successCount = 0;
    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.timeout;
    }
  }
}

// Usage
const paymentServiceCall = new CircuitBreaker(
  async (orderId) => {
    const response = await fetch(`https://payment-api.example.com/charge`, {
      method: 'POST',
      body: JSON.stringify({ orderId })
    });
    if (!response.ok) throw new Error('Payment API error');
    return response.json();
  },
  { failureThreshold: 5, timeout: 30000 }
);

export async function processPayment(orderId) {
  try {
    return await paymentServiceCall.execute(orderId);
  } catch (err) {
    if (err.message.includes('Circuit breaker')) {
      // Service is down, return cached result or queue for retry
      return { status: 'pending', reason: 'service_degraded' };
    }
    throw err;
  }
}

Pair circuit breakers with exponential backoff retry logic:

export async function callWithRetry(fn, options = {}) {
  const maxRetries = options.maxRetries || 3;
  const baseDelay = options.baseDelay || 100; // ms
  const maxDelay = options.maxDelay || 10000; // ms
  const backoffMultiplier = options.backoffMultiplier || 2;

  let lastError;
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (attempt < maxRetries - 1) {
        const delay = Math.min(
          baseDelay * Math.pow(backoffMultiplier, attempt),
          maxDelay
        );
        // Add jitter to prevent thundering herd
        const jitter = delay * 0.1 * Math.random();
        await new Promise(resolve => setTimeout(resolve, delay + jitter));
      }
    }
  }
  throw lastError;
}

// Usage
export async function fetchUserData(userId) {
  return callWithRetry(
    () => fetch(`/api/users/${userId}`).then(r => r.json()),
    { maxRetries: 3, baseDelay: 100 }
  );
}

The jitter prevents the thundering herd problem: if multiple clients retry at the same time, they’ll hammer the recovering service and knock it back down. Random jitter spreads out the retries.

Defending Against Common Attack Vectors

Beyond validation and rate limiting, a few other patterns are essential:

SQL Injection and NoSQL Injection

Use parameterized queries or ORMs that separate data from commands. Never concatenate user input into query strings:

// BAD: vulnerable to injection
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;

// GOOD: parameterized
const query = 'SELECT * FROM users WHERE email = ?';
const result = await db.query(query, [req.body.email]);

Denial of Service via Large Payloads

Set content-length limits at the HTTP middleware layer, before parsing:

import express from 'express';

const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ limit: '1mb' }));

Brute Force on Authentication

Combine rate limiting with progressive delays and account lockout:

export async function handleLogin(req, res) {
  const { email, password } = req.body;
  const clientId = req.ip;
  const attemptKey = `login-attempts:${clientId}:${email}`;
  
  const attempts = await redis.incr(attemptKey);
  if (attempts === 1) {
    await redis.expire(attemptKey, 900); // 15 minute window
  }
  
  if (attempts > 5) {
    return res.status(429).json({ error: 'Too many login attempts' });
  }
  
  const user = await User.findByEmail(email);
  if (!user || !await user.verifyPassword(password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // Success: clear attempts
  await redis.del(attemptKey);
  res.json({ token: generateToken(user) });
}

Architectural Considerations at Scale

As your traffic grows, these patterns interact in subtle ways:

Redis as a single point of failure: If your Redis instance goes down, your rate limiting stops working. Use Redis Sentinel or Redis Cluster for high availability. Consider your failure mode: do you fail open (allow all requests) or fail closed (reject all requests)? Fail open prevents outages but loses rate limiting protection. Fail closed prevents abuse but can cause cascading failures.

Distributed tracing: When requests flow through circuit breakers, retries, and rate limiters, debugging becomes harder. Use OpenTelemetry or similar to trace requests end-to-end, including which circuit breaker state was active, how many retries occurred, and whether rate limiting applied.

Metrics and alerting: Monitor these metrics in production:

  • Rate limit rejection rate (should be near zero for legitimate traffic)
  • Circuit breaker state transitions (indicates downstream service health)
  • Retry counts and backoff delays (indicates transient failures)
  • Request validation failure rate (indicates attack patterns or client bugs)

Set up alerts when any of these spike unexpectedly.

Practical Implementation Checklist

Here’s what to prioritize when hardening an existing API:

  • Start with request validation on all endpoints that accept user input. Use JSON Schema and compile validators once at startup.
  • Add rate limiting at the HTTP middleware level, before routing. Use Redis for distributed enforcement.
  • Wrap calls to external services with circuit breakers. Monitor their state in production.
  • Add exponential backoff retry logic with jitter for transient failures.
  • Set content-length limits to prevent DoS via large payloads.
  • Use parameterized queries and ORMs to prevent injection attacks.
  • Add comprehensive logging and metrics for all security layers.
  • Test failure scenarios: kill Redis, kill a downstream service, send malformed payloads. Verify your API degrades gracefully.

Conclusion

Secure, resilient REST APIs aren’t built by adding security as an afterthought. They’re designed from the start with validation, rate limiting, and resilience patterns baked in. The patterns covered here form the foundation of production systems: request validation with JSON Schema, distributed rate limiting with Redis and token buckets, and resilience via circuit breakers and retries.

Start small: validate inputs, add basic rate limiting, and wrap external calls with circuit breakers. Monitor what actually happens in production. As you scale, refine these patterns based on real traffic patterns and failure modes, not hypothetical scenarios.

Can I use in-memory rate limiting instead of Redis?

For a single-instance API, in-memory rate limiting is simpler and faster. But it doesn’t scale across multiple servers. Each instance maintains its own counter, so a client can make 100 requests per second to each instance, bypassing your limit. Use Redis only if you’re running multiple API instances behind a load balancer.

What happens if my Redis instance is slow or unreachable?

This depends on your failure mode. If you fail open (allow requests when Redis is unreachable), you lose rate limiting protection during the outage but keep your API running. If you fail closed (reject all requests), you prevent abuse but risk cascading failures. Most teams fail open and monitor Redis health separately.

How do I distinguish between legitimate retries and attack traffic?

Look at the pattern. Legitimate retries happen once or twice, with exponential backoff, from the same client. Attack traffic typically comes from many different IPs, hits the same endpoint repeatedly, and doesn’t back off. Use metrics and anomaly detection to identify patterns, then adjust rate limits or add CAPTCHA challenges.

Should I validate requests on the client side too?

Yes, for user experience. Client-side validation gives instant feedback without a round trip. But never trust it for security. Always validate on the server. Client-side validation is easily bypassed by sending requests directly to your API.

What’s the difference between rate limiting and throttling?

Rate limiting rejects requests that exceed the limit. Throttling slows down requests by queueing them or adding delays. For APIs, rate limiting is more common because it’s simpler and gives clear feedback to clients. Throttling is useful for background jobs where you want to control resource consumption without rejecting work.

Leave a Reply

Your email address will not be published. Required fields are marked *