Securityintermediate

API Security Checklist — Protecting Your Web APIs

A comprehensive guide to securing REST APIs. Covers rate limiting, input validation, output encoding, error handling, authentication, authorization, and HTTPS enforcement with practical code examples.

15 min read·Published Apr 22, 2026
securityapichecklistbest-practices

Why API Security Is Different

APIs are the backbone of modern applications. Unlike traditional web pages where a human interacts through a browser, APIs are consumed by code — mobile apps, SPAs, third-party integrations, and other services. This changes the threat model:

+----------------------------+----------------------------------------------+
| Traditional Web App        | API                                          |
+----------------------------+----------------------------------------------+
| Browser enforces some      | No browser — clients are code                |
| security (CORS, cookies)   |                                              |
+----------------------------+----------------------------------------------+
| Users interact via forms   | Requests crafted programmatically            |
+----------------------------+----------------------------------------------+
| Rate naturally limited by  | Automated requests — thousands per second    |
| human typing speed         |                                              |
+----------------------------+----------------------------------------------+
| Errors shown to humans     | Error details parsed by attackers            |
+----------------------------+----------------------------------------------+
| Session-based auth common  | Token-based auth common                      |
+----------------------------+----------------------------------------------+

Every API endpoint is an attack surface. Securing APIs requires explicit protection at every layer — you cannot rely on browser-enforced security mechanisms.

 API Request Lifecycle — Security Checkpoints:

 Client Request
      |
      v
 [HTTPS Enforcement] -- Is the connection encrypted?
      |
      v
 [Rate Limiting] -- Too many requests from this source?
      |
      v
 [Authentication] -- Who is making this request?
      |
      v
 [Authorization] -- Is this user allowed to do this?
      |
      v
 [Input Validation] -- Is the request data valid and safe?
      |
      v
 [Business Logic] -- Process the request
      |
      v
 [Output Encoding] -- Is the response safe?
      |
      v
 [Error Handling] -- Does the error reveal internal details?
      |
      v
 Response

1. Rate Limiting

Rate limiting prevents abuse by restricting how many requests a client can make in a given time window. Without it, attackers can brute-force credentials, scrape data, or DDoS your service.

Rate Limiting Strategies

+--------------------+------------------------------------------+--------------------+
| Strategy           | How It Works                             | Best For           |
+--------------------+------------------------------------------+--------------------+
| Fixed window       | Count requests per time window           | Simple APIs        |
| Sliding window     | Rolling count over time period           | Most APIs          |
| Token bucket       | Tokens replenish at fixed rate           | Burst-tolerant     |
| Leaky bucket       | Requests processed at constant rate      | Smooth throughput  |
+--------------------+------------------------------------------+--------------------+

Implementation with Express

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');

const redisClient = redis.createClient({ url: process.env.REDIS_URL });

// General API rate limit
const apiLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                    // 100 requests per window
  standardHeaders: true,       // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,
  message: { error: 'Too many requests, try again later' },
  keyGenerator: (req) => {
    // Use authenticated user ID if available, otherwise IP
    return req.user?.id || req.ip;
  },
});

// Strict rate limit for auth endpoints
const authLimiter = rateLimit({
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
  windowMs: 15 * 60 * 1000,
  max: 5,                      // Only 5 attempts per 15 minutes
  message: { error: 'Too many login attempts, try again in 15 minutes' },
  keyGenerator: (req) => {
    // Rate limit by IP AND email to prevent distributed attacks
    return `${req.ip}:${req.body.email || 'unknown'}`;
  },
  skipSuccessfulRequests: true, // Don't count successful logins
});

// Apply rate limits
app.use('/api/', apiLimiter);
app.use('/api/login', authLimiter);
app.use('/api/forgot-password', authLimiter);

Custom Rate Limiter (Without Library)

const rateLimitStore = new Map();

function createRateLimiter(maxRequests, windowMs) {
  return (req, res, next) => {
    const key = req.user?.id || req.ip;
    const now = Date.now();

    if (!rateLimitStore.has(key)) {
      rateLimitStore.set(key, { count: 1, resetAt: now + windowMs });
      return next();
    }

    const record = rateLimitStore.get(key);

    if (now > record.resetAt) {
      // Window expired, reset
      record.count = 1;
      record.resetAt = now + windowMs;
      return next();
    }

    record.count++;

    if (record.count > maxRequests) {
      const retryAfter = Math.ceil((record.resetAt - now) / 1000);
      res.setHeader('Retry-After', retryAfter);
      return res.status(429).json({
        error: 'Rate limit exceeded',
        retryAfter,
      });
    }

    // Set rate limit headers
    res.setHeader('X-RateLimit-Limit', maxRequests);
    res.setHeader('X-RateLimit-Remaining', maxRequests - record.count);
    res.setHeader('X-RateLimit-Reset', Math.ceil(record.resetAt / 1000));

    next();
  };
}

// Usage
app.use('/api/', createRateLimiter(100, 15 * 60 * 1000));

2. Input Validation and Sanitization

Never trust client input. Every request parameter, header, and body field must be validated before processing. Use a schema validation library — do not write validation logic manually for complex objects.

Schema Validation with Zod

const { z } = require('zod');

// Define schemas
const createUserSchema = z.object({
  email: z.string().email().max(254),
  password: z.string().min(12).max(128),
  name: z.string().min(1).max(100).regex(/^[a-zA-Z\s'-]+$/),
  age: z.number().int().min(13).max(150).optional(),
});

const updateArticleSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  body: z.string().min(1).max(50000).optional(),
  tags: z.array(z.string().max(50)).max(10).optional(),
  published: z.boolean().optional(),
});

const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).max(1000).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(['newest', 'oldest', 'popular']).default('newest'),
});

// Validation middleware factory
function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.issues.map(issue => ({
          field: issue.path.join('.'),
          message: issue.message,
        })),
      });
    }

    req.validatedBody = result.data;
    next();
  };
}

function validateQuery(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.query);

    if (!result.success) {
      return res.status(400).json({
        error: 'Invalid query parameters',
        details: result.error.issues.map(issue => ({
          field: issue.path.join('.'),
          message: issue.message,
        })),
      });
    }

    req.validatedQuery = result.data;
    next();
  };
}

// Usage
app.post('/api/users', validateBody(createUserSchema), async (req, res) => {
  const { email, password, name } = req.validatedBody;
  // Safe to use — validated and typed
  const user = await createUser({ email, password, name });
  res.json(user);
});

app.get('/api/articles', validateQuery(paginationSchema), async (req, res) => {
  const { page, limit, sort } = req.validatedQuery;
  const articles = await getArticles({ page, limit, sort });
  res.json(articles);
});

Sanitization for Specific Contexts

const validator = require('validator');

// Sanitize different input types
function sanitizeInput(input, type) {
  switch (type) {
    case 'string':
      return validator.trim(validator.escape(input));
    case 'email':
      return validator.normalizeEmail(input);
    case 'url':
      if (!validator.isURL(input, { protocols: ['http', 'https'] })) {
        throw new Error('Invalid URL');
      }
      return input;
    case 'integer':
      if (!validator.isInt(String(input))) {
        throw new Error('Invalid integer');
      }
      return parseInt(input, 10);
    case 'uuid':
      if (!validator.isUUID(input)) {
        throw new Error('Invalid UUID');
      }
      return input;
    default:
      throw new Error(`Unknown type: ${type}`);
  }
}

SQL Injection Prevention

Always use parameterized queries. Never concatenate user input into SQL strings.

// VULNERABLE — SQL injection
app.get('/api/users/:id', async (req, res) => {
  const result = await db.query(
    `SELECT * FROM users WHERE id = '${req.params.id}'`
    // Attacker: /api/users/1' OR '1'='1' --
    // Becomes: SELECT * FROM users WHERE id = '1' OR '1'='1' --'
  );
  res.json(result);
});

// SAFE — Parameterized query
app.get('/api/users/:id', async (req, res) => {
  const result = await db.query(
    'SELECT * FROM users WHERE id = $1',
    [req.params.id]
  );
  res.json(result);
});

// SAFE — Using an ORM (Prisma example)
app.get('/api/users/:id', async (req, res) => {
  const user = await prisma.user.findUnique({
    where: { id: req.params.id },
  });
  res.json(user);
});

NoSQL Injection Prevention

// VULNERABLE — MongoDB operator injection
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  // Attacker sends: { email: { $gt: "" }, password: { $gt: "" } }
  // This matches the first user in the database
  const user = await db.collection('users').findOne({ email, password });
  res.json(user);
});

// SAFE — Validate types before querying
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  if (typeof email !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ error: 'Invalid input types' });
  }

  const user = await db.collection('users').findOne({
    email: String(email),  // Force string type
  });

  // Then verify password with bcrypt (never query password directly)
  if (user && await bcrypt.compare(password, user.passwordHash)) {
    res.json({ success: true });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

3. Output Encoding

API responses must not contain unencoded user data that could be interpreted as code in the consuming application.

// Set proper Content-Type to prevent MIME sniffing
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  next();
});

// JSON responses — ensure Content-Type is correct
app.get('/api/user/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);

  // Express res.json() sets Content-Type: application/json automatically
  // But be explicit if using res.send()
  res.setHeader('Content-Type', 'application/json');
  res.json({
    id: user.id,
    name: user.name,
    // Don't include sensitive fields
    // passwordHash: user.passwordHash,  // NEVER
    // resetToken: user.resetToken,      // NEVER
  });
});

// Filter sensitive fields from all responses
function sanitizeUserResponse(user) {
  const { passwordHash, resetToken, mfaSecret, ...safe } = user;
  return safe;
}

// Prevent JSON array responses (historical vulnerability)
// Some older browsers could interpret top-level JSON arrays as JavaScript
app.get('/api/articles', async (req, res) => {
  const articles = await db.articles.findAll();
  // Wrap in object instead of returning bare array
  res.json({ data: articles, count: articles.length });
});

4. Error Handling (No Information Leaks)

Error responses must not expose internal details like stack traces, database schemas, file paths, or technology versions. These details help attackers understand your system.

// VULNERABLE — Exposes internal details
app.get('/api/users/:id', async (req, res) => {
  try {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
    res.json(user);
  } catch (err) {
    // Exposes: database type, table names, query structure, file paths
    res.status(500).json({
      error: err.message,
      stack: err.stack,
      query: err.query,
    });
  }
});

// SAFE — Generic error response with internal logging
const { randomUUID } = require('crypto');

function errorHandler(err, req, res, next) {
  const errorId = randomUUID();

  // Log full error internally (for debugging)
  console.error({
    errorId,
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    userId: req.user?.id,
    timestamp: new Date().toISOString(),
  });

  // Determine status code
  const statusCode = err.statusCode || err.status || 500;

  // Return safe error to client
  if (statusCode >= 500) {
    // Server errors — never expose details
    res.status(statusCode).json({
      error: 'Internal server error',
      errorId,  // Allow user to reference this in support requests
    });
  } else {
    // Client errors (4xx) — safe to provide detail
    res.status(statusCode).json({
      error: err.publicMessage || 'Bad request',
      ...(err.details ? { details: err.details } : {}),
      errorId,
    });
  }
}

// Custom error class for controlled errors
class ApiError extends Error {
  constructor(statusCode, publicMessage, details = null) {
    super(publicMessage);
    this.statusCode = statusCode;
    this.publicMessage = publicMessage;
    this.details = details;
  }
}

// Usage in routes
app.get('/api/articles/:id', async (req, res, next) => {
  try {
    const article = await db.articles.findById(req.params.id);

    if (!article) {
      throw new ApiError(404, 'Article not found');
    }

    res.json(article);
  } catch (err) {
    next(err); // Pass to error handler
  }
});

// Register error handler last
app.use(errorHandler);

Hide Server Headers

const helmet = require('helmet');

app.use(helmet());

// Specifically hide the X-Powered-By header
app.disable('x-powered-by');

// Remove Server header (if possible — depends on reverse proxy)
app.use((req, res, next) => {
  res.removeHeader('X-Powered-By');
  next();
});

5. Authentication and Authorization for APIs

API Key Authentication

For service-to-service communication or third-party integrations:

// API key validation middleware
function validateApiKey(req, res, next) {
  const apiKey = req.headers['x-api-key'];

  if (!apiKey) {
    return res.status(401).json({ error: 'API key required' });
  }

  // Hash the key and look up in database
  // (Store hashed keys, not plain text)
  const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
  const keyRecord = await db.apiKeys.findByHash(keyHash);

  if (!keyRecord || keyRecord.revoked) {
    return res.status(401).json({ error: 'Invalid API key' });
  }

  // Check key permissions
  req.apiClient = {
    clientId: keyRecord.clientId,
    permissions: keyRecord.permissions,
  };

  // Log usage
  await db.apiKeyUsage.log({
    keyId: keyRecord.id,
    endpoint: req.path,
    method: req.method,
    ip: req.ip,
    timestamp: new Date(),
  });

  next();
}

JWT Authentication for APIs

const jwt = require('jsonwebtoken');

function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Bearer token required' });
  }

  const token = authHeader.slice(7);

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],  // Explicitly set algorithm
      issuer: 'myapp.com',   // Validate issuer
      audience: 'api',       // Validate audience
    });

    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
    };

    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

6. HTTPS Enforcement

All API communication must use HTTPS. Reject or redirect HTTP requests.

// Middleware to enforce HTTPS
function requireHTTPS(req, res, next) {
  // Check for HTTPS (including behind reverse proxy)
  if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
    return next();
  }

  // For APIs, reject rather than redirect
  // (redirects can cause silent credential leaks)
  return res.status(403).json({
    error: 'HTTPS required',
    detail: 'This API only accepts HTTPS connections',
  });
}

app.use(requireHTTPS);

// Set HSTS header
app.use((req, res, next) => {
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=63072000; includeSubDomains; preload'
  );
  next();
});

7. Request Size Limits

Prevent denial of service via oversized payloads:

const express = require('express');

// Limit JSON body size
app.use(express.json({ limit: '1mb' }));

// Limit URL-encoded body size
app.use(express.urlencoded({ limit: '1mb', extended: true }));

// Different limits for different routes
app.use('/api/upload', express.json({ limit: '10mb' }));
app.use('/api/', express.json({ limit: '100kb' }));

8. Logging and Monitoring

// Security event logging
function securityLog(event) {
  console.log(JSON.stringify({
    ...event,
    timestamp: new Date().toISOString(),
    service: 'api',
  }));
}

// Log authentication failures
app.post('/api/login', async (req, res) => {
  try {
    const user = await authenticate(req.body);
    securityLog({
      type: 'AUTH_SUCCESS',
      userId: user.id,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    });
    res.json({ success: true });
  } catch {
    securityLog({
      type: 'AUTH_FAILURE',
      email: req.body.email,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    });
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// Log authorization failures
function requirePermission(permission) {
  return (req, res, next) => {
    if (!hasPermission(req.user, permission)) {
      securityLog({
        type: 'AUTHZ_FAILURE',
        userId: req.user?.id,
        permission,
        path: req.path,
        method: req.method,
        ip: req.ip,
      });
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// Log rate limit violations
function onRateLimitExceeded(req, res) {
  securityLog({
    type: 'RATE_LIMIT',
    ip: req.ip,
    path: req.path,
    userId: req.user?.id,
  });
}

9. API Versioning (Security Consideration)

Outdated API versions may have known vulnerabilities. Plan for deprecation:

// Version-aware middleware
function apiVersion(req, res, next) {
  const version = req.headers['api-version'] || req.path.match(/^\/api\/(v\d+)\//)?.[1] || 'v1';

  const deprecatedVersions = ['v1'];
  const sunsetVersions = [];

  if (sunsetVersions.includes(version)) {
    return res.status(410).json({
      error: `API ${version} is no longer available`,
      migration: 'https://docs.example.com/migration',
    });
  }

  if (deprecatedVersions.includes(version)) {
    res.setHeader('Deprecation', 'true');
    res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
    res.setHeader('Link', '<https://docs.example.com/migration>; rel="deprecation"');
  }

  req.apiVersion = version;
  next();
}

Complete API Security Checklist

+------+----------------------------------------------+----------+
| #    | Check                                        | Status   |
+------+----------------------------------------------+----------+
|      | TRANSPORT                                    |          |
| 1    | HTTPS enforced (HTTP rejected, not redirected)| [ ]      |
| 2    | TLS 1.2+ with strong cipher suites           | [ ]      |
| 3    | HSTS header set                              | [ ]      |
+------+----------------------------------------------+----------+
|      | AUTHENTICATION                               |          |
| 4    | Auth required on all non-public endpoints     | [ ]      |
| 5    | JWT algorithm explicitly set in verify()      | [ ]      |
| 6    | API keys hashed before storage                | [ ]      |
| 7    | Token expiration enforced                     | [ ]      |
+------+----------------------------------------------+----------+
|      | AUTHORIZATION                                |          |
| 8    | Authorization checked on every endpoint       | [ ]      |
| 9    | Resource-level ownership verified             | [ ]      |
| 10   | No IDOR vulnerabilities                       | [ ]      |
+------+----------------------------------------------+----------+
|      | INPUT                                        |          |
| 11   | All input validated with schema (Zod/Joi)     | [ ]      |
| 12   | SQL injection prevented (parameterized queries)| [ ]     |
| 13   | NoSQL injection prevented (type checking)     | [ ]      |
| 14   | Request body size limited                     | [ ]      |
| 15   | File uploads validated (type, size)           | [ ]      |
+------+----------------------------------------------+----------+
|      | OUTPUT                                       |          |
| 16   | Content-Type set correctly on responses       | [ ]      |
| 17   | X-Content-Type-Options: nosniff               | [ ]      |
| 18   | Sensitive fields stripped from responses       | [ ]      |
+------+----------------------------------------------+----------+
|      | ERROR HANDLING                                |          |
| 19   | No stack traces in production responses       | [ ]      |
| 20   | No database/query details in errors           | [ ]      |
| 21   | Error IDs for support reference               | [ ]      |
| 22   | Server header hidden (X-Powered-By removed)   | [ ]      |
+------+----------------------------------------------+----------+
|      | RATE LIMITING                                |          |
| 23   | General rate limit on all endpoints           | [ ]      |
| 24   | Strict rate limit on auth endpoints           | [ ]      |
| 25   | Rate limit headers returned (Retry-After)     | [ ]      |
+------+----------------------------------------------+----------+
|      | CORS                                         |          |
| 26   | CORS origin is allowlist (not wildcard)       | [ ]      |
| 27   | CORS credentials configured correctly         | [ ]      |
| 28   | Preflight (OPTIONS) handled                   | [ ]      |
+------+----------------------------------------------+----------+
|      | LOGGING                                      |          |
| 29   | Auth failures logged                          | [ ]      |
| 30   | Rate limit violations logged                  | [ ]      |
| 31   | Authorization failures logged                 | [ ]      |
| 32   | No sensitive data in logs (passwords, tokens) | [ ]      |
+------+----------------------------------------------+----------+

Summary

API security requires explicit protection at every layer because APIs lack the browser's built-in security mechanisms.

Key takeaways:

  1. Rate limit everything — General limits on all endpoints, strict limits on authentication.
  2. Validate all input — Use schema validation (Zod, Joi). Never trust client data.
  3. Parameterize queries — Prevent SQL and NoSQL injection at the query level.
  4. Encode output — Set correct Content-Type, strip sensitive fields, prevent MIME sniffing.
  5. Handle errors safely — Never expose stack traces, database details, or internal paths.
  6. Authenticate and authorize — Every endpoint must verify identity and permissions.
  7. Enforce HTTPS — Reject (do not redirect) HTTP API requests.
  8. Log security events — Authentication failures, authorization denials, and rate limit violations.

No API is secure by default. Security must be explicitly implemented, tested, and maintained.

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles