Skip to content
Codeloom
REST APIs

REST API Security Checklist: OWASP, Auth, CORS & Input Validation

A practical security checklist for REST APIs covering OWASP API Top 10, authentication, authorization, input validation, CORS, and common vulnerabilities with fixes.

·7 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • The OWASP API Security Top 10 and how to address each risk
  • How to implement authentication and authorization correctly
  • Input validation patterns that prevent injection attacks
  • How to configure CORS securely
  • Security headers and transport security for APIs

Prerequisites

  • Basic REST API design knowledge
  • Understanding of HTTP and authentication concepts

API security is not a feature you add later. It is a set of constraints you build into every layer of your API from day one. This article walks through the most critical security controls with real code examples and a checklist you can use during code review.

OWASP API Security Top 10 (2023)

The OWASP API Security Project identifies the ten most critical API vulnerabilities. Here is the list with practical mitigations:

1. Broken Object Level Authorization (BOLA)

The most common API vulnerability. A user can access another user’s data by changing an ID in the URL.

GET /api/users/123/orders    # User 456 can see user 123's orders

Fix: Always verify resource ownership in your authorization logic:

app.get('/api/users/:userId/orders', authenticate, async (req, res) => {
  // WRONG: trusting the URL parameter
  // const orders = await db.orders.findByUserId(req.params.userId);

  // RIGHT: enforce ownership
  if (req.user.id !== req.params.userId && !req.user.isAdmin) {
    return res.status(403).json({
      type: 'https://api.example.com/errors/forbidden',
      title: 'Forbidden',
      status: 403,
      detail: 'You can only access your own orders.',
    });
  }

  const orders = await db.orders.findByUserId(req.params.userId);
  res.json({ data: orders });
});

2. Broken Authentication

Weak authentication lets attackers impersonate users.

Mitigations:

  • Use short-lived JWTs (15 minutes) with refresh tokens
  • Validate JWT signature, issuer, audience, and expiration on every request
  • Rate limit login and token endpoints
  • Never store tokens in localStorage (use httpOnly cookies)
import jwt from 'jsonwebtoken';

function authenticate(req, res, next) {
  const token = req.cookies.access_token;
  if (!token) return res.status(401).json({ status: 401, title: 'Authentication required' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'api.example.com',
      audience: 'api.example.com',
    });
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ status: 401, title: 'Invalid or expired token' });
  }
}

3. Broken Object Property Level Authorization

The API exposes properties that the user should not see or modify.

// WRONG: returning the full database record
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user); // includes password_hash, internal_notes, etc.
});

// RIGHT: explicit allowlist of returned fields
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json({
    data: {
      id: user.id,
      name: user.name,
      email: user.email,
      avatar_url: user.avatar_url,
    },
  });
});

Also protect against mass assignment on writes:

// WRONG: spreading the entire body into the database
await db.users.update(userId, req.body);

// RIGHT: pick only allowed fields
const { name, email, avatar_url } = req.body;
await db.users.update(userId, { name, email, avatar_url });

4. Unrestricted Resource Consumption

No rate limiting, no pagination limits, no request size limits.

import express from 'express';

const app = express();

// Limit request body size
app.use(express.json({ limit: '100kb' }));

// Enforce pagination limits
function enforcePagination(req, res, next) {
  req.query.limit = Math.min(parseInt(req.query.limit) || 20, 100);
  next();
}

// Rate limiting (see the rate limiting article for full implementation)
app.use(rateLimitMiddleware);

5. Broken Function Level Authorization

An unprivileged user can call admin endpoints.

function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        status: 403,
        title: 'Insufficient permissions',
        detail: `Required role: ${roles.join(' or ')}`,
      });
    }
    next();
  };
}

// Admin-only endpoints
app.delete('/api/users/:id', authenticate, requireRole('admin'), deleteUser);
app.post('/api/system/config', authenticate, requireRole('admin'), updateConfig);

Input validation

Never trust client input. Validate every field on every request.

Schema validation with Zod

import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1).max(100).trim(),
  email: z.string().email().max(255).toLowerCase(),
  age: z.number().int().min(18).max(150).optional(),
  role: z.enum(['user', 'editor']).default('user'),
});

app.post('/api/users', authenticate, async (req, res) => {
  const result = createUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(422).json({
      type: 'https://api.example.com/errors/validation-failed',
      title: 'Validation failed',
      status: 422,
      errors: result.error.issues.map(issue => ({
        field: issue.path.join('.'),
        message: issue.message,
        code: issue.code,
      })),
    });
  }

  const user = await db.users.create(result.data);
  res.status(201).json({ data: user });
});

SQL injection prevention

Always use parameterized queries:

// WRONG: string concatenation
const query = `SELECT * FROM users WHERE id = '${req.params.id}'`;

// RIGHT: parameterized query
const query = 'SELECT * FROM users WHERE id = $1';
const result = await db.query(query, [req.params.id]);

Path traversal prevention

import path from 'path';

app.get('/api/files/:filename', (req, res) => {
  const safeName = path.basename(req.params.filename); // strips ../ etc.
  const filePath = path.join('/uploads', safeName);

  if (!filePath.startsWith('/uploads/')) {
    return res.status(400).json({ title: 'Invalid filename' });
  }

  res.sendFile(filePath);
});

CORS configuration

Cross-Origin Resource Sharing must be configured deliberately. Never use Access-Control-Allow-Origin: * if your API uses cookies or authentication.

import cors from 'cors';

const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
];

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

    if (allowedOrigins.includes(origin)) {
      callback(null, origin);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400, // cache preflight for 24 hours
}));

Security headers

Add these headers to every API response:

app.use((req, res, next) => {
  // Prevent MIME type sniffing
  res.set('X-Content-Type-Options', 'nosniff');

  // Prevent clickjacking (for any HTML error pages)
  res.set('X-Frame-Options', 'DENY');

  // Strict transport security
  res.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');

  // Disable caching for authenticated endpoints
  if (req.user) {
    res.set('Cache-Control', 'no-store');
  }

  next();
});

Transport security

  • Always use HTTPS. No exceptions.
  • Redirect HTTP to HTTPS at the load balancer level.
  • Use TLS 1.2+ only. Disable TLS 1.0 and 1.1.
  • Use HSTS headers to prevent downgrade attacks.

API key management

If your API uses API keys:

// Hash API keys before storing them
import { createHash } from 'crypto';

function hashApiKey(key) {
  return createHash('sha256').update(key).digest('hex');
}

// Look up by hash, never store the raw key
async function authenticateApiKey(req, res, next) {
  const key = req.headers['x-api-key'];
  if (!key) return res.status(401).json({ title: 'API key required' });

  const hash = hashApiKey(key);
  const client = await db.apiKeys.findByHash(hash);

  if (!client || client.revoked_at) {
    return res.status(401).json({ title: 'Invalid API key' });
  }

  req.client = client;
  next();
}

The checklist

Use this during code review:

Authentication:

  • All endpoints require authentication (except health checks and docs)
  • JWTs are validated for signature, expiry, issuer, and audience
  • Refresh tokens are stored securely (httpOnly, secure, sameSite cookies)
  • Login endpoints are rate limited

Authorization:

  • Every endpoint checks resource ownership (BOLA prevention)
  • Admin endpoints require admin role
  • Response fields are explicitly allowlisted
  • Write endpoints only accept allowed fields (no mass assignment)

Input validation:

  • All input is validated with a schema library (Zod, Joi, etc.)
  • Request body size is limited
  • Pagination has a maximum page size
  • File uploads are validated for type and size

Transport & headers:

  • HTTPS only, TLS 1.2+
  • HSTS header set
  • CORS configured with explicit origin allowlist
  • X-Content-Type-Options: nosniff set
  • Authenticated responses have Cache-Control: no-store

Data protection:

  • SQL queries use parameterized statements
  • API keys are hashed before storage
  • Sensitive data (passwords, tokens) never appears in logs
  • Error responses do not leak stack traces or internal paths

Summary

API security is not a single feature but a set of controls applied at every layer. Start with the OWASP API Top 10 as your threat model. Validate all input with schema validation. Enforce authorization at the resource level, not just the endpoint level. Configure CORS explicitly. And use the checklist above in every code review. An API that is secure by default is much easier to maintain than one that needs security patches after every penetration test.