API Security

REST API Security Best Practices: Authentication, Input Validation, and CORS

A practical guide to securing REST APIs — covering authentication patterns, HTTPS enforcement, rate limiting, input validation, error handling, CORS configuration, and versioning strategies.

September 28, 20257 min readShipSafer Team

REST APIs are the backbone of modern web applications, mobile apps, and service integrations. They are also one of the most attacked surfaces in any organization's infrastructure. Unlike web interfaces where a browser enforces many security policies, APIs are directly callable by anyone with an HTTP client — making explicit security controls essential.

This guide covers the security fundamentals every production REST API must implement.

1. Authentication Patterns

Choosing the right authentication mechanism depends on your API's use case.

API Keys (Machine-to-Machine)

API keys are long, random strings used to authenticate server-to-server integrations. They are simple and effective when used correctly.

// Generating a cryptographically secure API key
import crypto from 'crypto';

function generateApiKey(prefix: string): string {
  const key = crypto.randomBytes(32).toString('hex');
  return `${prefix}_${key}`;
  // Example: shss_a3f1b2c4d5e6...
}

// Validating an API key in middleware
async function validateApiKey(req: Request, res: Response, next: NextFunction) {
  const key = req.headers['x-api-key'] as string | undefined;
  if (!key) {
    return res.status(401).json({ error: 'API key required' });
  }

  // Hash the key before DB lookup to protect stored keys
  const hash = crypto.createHash('sha256').update(key).digest('hex');
  const keyRecord = await ApiKey.findOne({ keyHash: hash, active: true });

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

  req.apiKeyRecord = keyRecord;
  next();
}

Key rules for API keys:

  • Generate with at least 256 bits of entropy.
  • Store only the hash (SHA-256), never the plaintext.
  • Use a prefix for easy identification in logs and source code scans.
  • Support key rotation without downtime by allowing multiple active keys.

JWT (User-Facing APIs)

JWTs are appropriate for authenticating human users, particularly in stateless architectures. Store them in HTTP-only cookies rather than localStorage:

import jwt from 'jsonwebtoken';
import { Response } from 'express';

function issueToken(userId: string, role: string, res: Response) {
  const token = jwt.sign(
    { userId, role },
    process.env.JWT_SECRET,
    { expiresIn: '7d', algorithm: 'HS256' }
  );

  res.cookie('authToken', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
}

OAuth 2.0 / OIDC (Third-Party Authorization)

When your API needs to act on behalf of users from another system (GitHub, Google, Slack), use OAuth 2.0. Use a proven library rather than implementing the flow yourself:

npm install openid-client

2. Enforce HTTPS

Never serve an API over plain HTTP in production. Redirect HTTP to HTTPS at the load balancer or application level:

app.use((req, res, next) => {
  if (
    process.env.NODE_ENV === 'production' &&
    req.headers['x-forwarded-proto'] !== 'https'
  ) {
    return res.redirect(301, `https://${req.hostname}${req.originalUrl}`);
  }
  next();
});

Set HSTS to tell browsers to always use HTTPS, even on the first request after the redirect:

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

3. Rate Limiting

Rate limiting protects against brute-force attacks, credential stuffing, scraping, and abuse. Apply different limits to different endpoints:

import rateLimit from 'express-rate-limit';

// Strict limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  message: { error: 'Too many login attempts. Please try again later.' },
  standardHeaders: true,  // Return RateLimit-* headers
  legacyHeaders: false,
});

// General API limit
const apiLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100,
  message: { error: 'Rate limit exceeded.' },
  keyGenerator: (req) => req.headers['x-api-key'] as string || req.ip,
});

app.use('/api/auth/', authLimiter);
app.use('/api/', apiLimiter);

For distributed deployments, use Redis-backed rate limiting so limits are shared across instances:

import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { createClient } from 'redis';

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

const limiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
});

4. Input Validation and Sanitization

Every request parameter — path params, query strings, request body, headers — should be validated before use.

import { z } from 'zod';

const CreateProductSchema = z.object({
  name: z.string().min(1).max(200).trim(),
  price: z.number().positive().max(1_000_000),
  category: z.enum(['electronics', 'clothing', 'food']),
  description: z.string().max(2000).optional(),
  tags: z.array(z.string().max(50)).max(10).optional(),
});

app.post('/api/products', async (req, res) => {
  const result = CreateProductSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: 'Invalid input',
      details: result.error.flatten().fieldErrors,
    });
  }

  const product = await Product.create({
    ...result.data,
    userId: req.user.userId,
  });

  res.status(201).json({ data: product });
});

For path parameters, validate before using in database queries:

const UUIDSchema = z.string().uuid();

app.get('/api/products/:id', async (req, res) => {
  const idResult = UUIDSchema.safeParse(req.params.id);
  if (!idResult.success) {
    return res.status(400).json({ error: 'Invalid product ID format' });
  }

  const product = await Product.findOne({ productId: idResult.data });
  if (!product) return res.status(404).json({ error: 'Not found' });

  res.json({ data: product });
});

5. Proper HTTP Methods and Idempotency

Use HTTP methods semantically and consistently:

MethodUse CaseIdempotentSafe
GETRetrieve resourceYesYes
POSTCreate resourceNoNo
PUTReplace resourceYesNo
PATCHPartial updateNoNo
DELETERemove resourceYesNo

Never use GET requests for state-changing operations. GET requests are cached, logged, and bookmarked — a GET /api/admin/delete-all-users?confirm=true is a disaster waiting to happen.

6. API Versioning

Version your API from day one. Breaking changes are inevitable, and versioning prevents you from forcing all clients to upgrade simultaneously:

// URL versioning (most common)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// Header versioning (cleaner URLs)
app.use('/api', (req, res, next) => {
  const version = req.headers['api-version'] ?? 'v1';
  if (version === 'v2') {
    return v2Handler(req, res, next);
  }
  return v1Handler(req, res, next);
});

Deprecate old versions with warning headers before removing them:

app.use('/api/v1', (req, res, next) => {
  res.setHeader('Deprecation', 'true');
  res.setHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
  res.setHeader('Link', '</api/v2>; rel="successor-version"');
  next();
});

7. Error Message Exposure

Error responses are a common source of information disclosure. Internal errors, stack traces, and database error messages should never reach the client.

// Generic error handler for Express
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // Log the full error internally
  logger.error('Unhandled error', {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });

  // Return a generic message to the client
  const status = (err as any).status ?? 500;
  const message = status < 500
    ? err.message // Client errors (4xx) can be descriptive
    : 'An unexpected error occurred. Please try again.'; // Server errors must be generic

  res.status(status).json({
    error: message,
    requestId: req.id, // Include a reference ID for support
  });
});

Never expose:

  • Stack traces (err.stack)
  • SQL or NoSQL error messages (may reveal schema)
  • Internal service names or IPs
  • Library versions in error messages

8. CORS Configuration

Cross-Origin Resource Sharing headers tell browsers which origins can call your API. A misconfigured CORS policy is one of the most common API security issues.

import cors from 'cors';

const ALLOWED_ORIGINS = [
  'https://app.yourdomain.com',
  'https://yourdomain.com',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean) as string[];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, curl, Postman)
    if (!origin) return callback(null, true);

    if (!ALLOWED_ORIGINS.includes(origin)) {
      return callback(new Error(`Origin ${origin} not allowed`));
    }
    callback(null, true);
  },
  credentials: true, // Required if using cookies
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  maxAge: 600, // Cache preflight responses for 10 minutes
}));

Avoid origin: '*' for authenticated APIs — it allows any website to make requests to your API on behalf of your users.

Summary Checklist

ControlImplementation
AuthenticationAPI keys (machine-to-machine), JWT in httpOnly cookie (user-facing)
HTTPSRedirect middleware + HSTS header
Rate limitingexpress-rate-limit + Redis for distributed deployments
Input validationZod/Joi schema validation on every endpoint
HTTP methodsSemantic usage (no GET for mutations)
VersioningURL versioning from day one
Error handlingGeneric 500 messages; full errors logged server-side only
CORSExplicit origin allowlist; avoid * for authenticated APIs

REST API security is about removing the implicit trust that default configurations assume. By validating every input, authenticating every request, and sanitizing every error response, you build an API that is both robust and resilient to the attack patterns that target APIs daily.

rest api
api security
authentication
cors
rate limiting

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.