DEV Community

Alex Chen
Alex Chen

Posted on

REST API Security: What Every Developer Must Know (2026)

REST API Security: What Every Developer Must Know (2026)

Your API is only as secure as its weakest endpoint. Here's what you need to know to protect it.

The Threat Model

Who attacks your API?
→ Bots scanning for vulnerabilities (automated, high volume)
→ Scrapers stealing your data
→ Attackers probing for injection points
→ Malicious users exploiting business logic
→ Anyone who reads your public docs and finds gaps

What they want:
→ User data (PII, credentials, payment info)
→ Free access to paid features
→ Your server as a botnet node
→ Data they can sell or exploit

Good news: Most attacks target KNOWN vulnerability patterns.
Fix the basics → eliminate 90% of risk.
Enter fullscreen mode Exit fullscreen mode

1. Authentication Done Right

// ❌ Bad: Sending credentials in URL or query params
// GET /api/users?email=user@domain.com&password=secret
// These get logged in access logs, browser history, proxy logs!

// ❌ Bad: Basic Auth over non-HTTPS
// Base64 encoding is NOT encryption (it's trivially reversible)

// ✅ Good: Bearer tokens in Authorization header
GET /api/users HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

// Token best practices:
const tokenConfig = {
  // Access tokens: short-lived (15-30 min)
  accessTokenTTL: 15 * 60 * 1000,

  // Refresh tokens: longer-lived (7-30 days), stored securely
  refreshTokenTTL: 7 * 24 * 60 * 60 * 1000,

  // Always include expiry check
  validateExpiry: true,

  // Include issued-at time for clock drift tolerance
  leeway: 60, // Accept tokens up to 60 seconds old/expired

  // Token should include:
  payload: {
    sub: "user_123",       // Subject (user ID)
    iat: Date.now(),        // Issued at
    exp: Date.now() + 900,  // Expiration
    jti: crypto.randomUUID(), // Unique ID (for revocation)
    role: "user",           // For authorization
  }
};

// Validate token on EVERY request
function authenticate(req) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) throw new AuthError('Missing token');

  const token = header.slice(7);
  try {
    const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] });

    // Check if token is revoked (for logout/blacklist)
    if (await isRevoked(payload.jti)) throw new AuthError('Token revoked');

    return payload;
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      throw new AuthError('Token expired', { code: 'TOKEN_EXPIRED' });
    }
    throw new AuthError('Invalid token');
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Input Validation: Your First Line of Defense

// NEVER trust client input. Ever.

// Layer 1: Type validation before anything else
function validateInput(data, schema) {
  const errors = [];
  for (const [field, rules] of Object.entries(schema)) {
    const value = data[field];

    if (rules.required && (value === undefined || value === null || value === '')) {
      errors.push({ field, issue: `${field} is required` });
      continue;
    }
    if (value === undefined && !rules.required) continue; // Skip optional

    // Type checks
    if (rules.type === 'string') {
      if (typeof value !== 'string') errors.push({ field, issue: 'Must be string' });
      else if (rules.maxLength && value.length > rules.maxLength)
        errors.push({ field, issue: `Max ${rules.maxLength} chars` });
      else if (rules.pattern && !rules.pattern.test(value))
        errors.push({ field, issue: 'Invalid format' });
      else if (rules.enum && !rules.enum.includes(value))
        errors.push({ field, issue: `Must be one of: ${rules.enum.join(', ')}` });
    }

    if (rules.type === 'number') {
      const num = Number(value);
      if (isNaN(num)) errors.push({ field, issue: 'Must be number' });
      else if (rules.min !== undefined && num < rules.min)
        errors.push({ field, issue: `Minimum is ${rules.min}` });
      else if (rules.max !== undefined && num > rules.max)
        errors.push({ field, issue: `Maximum is ${rules.max}` });
    }

    if (rules.type === 'array') {
      if (!Array.isArray(value)) errors.push({ field, issue: 'Must be array' });
      else if (rules.maxItems && value.length > rules.maxItems)
        errors.push({ field, issue: `Max ${rules.maxItems} items` });
    }

    if (rules.type === 'object' && typeof value !== 'object')
      errors.push({ field, issue: 'Must be object' });
  }
  return errors;
}

// Layer 2: Sanitization (clean what passes through)
function sanitize(str) {
  return str
    .replace(/<script\b[^<]*(?:<\/script>)?>/gi, '') // Remove script tags
    .replace(/on\w+\s*=/gi, '')                       // Remove event handlers
    .replace(/javascript:/gi, '');                     // Remove JS protocol
}

// Layer 3: Length limits (prevent DoS via huge payloads)
app.use(express.json({ limit: '1mb' })); // Body size limit

// Practical example: User registration validation
const registerSchema = {
  email: {
    type: 'string',
    required: true,
    maxLength: 254,
    pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
  },
  password: {
    type: 'string',
    required: true,
    minLength: 8,
    maxLength: 128,
    pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
  },
  name: {
    type: 'string',
    required: true,
    minLength: 1,
    maxLength: 100,
  },
  role: {
    type: 'string',
    required: false,
    enum: ['user', 'creator'],
  },
};
Enter fullscreen mode Exit fullscreen mode

3. SQL Injection Prevention

-- ❌ VULNERABLE: String concatenation
SELECT * FROM users WHERE email = '$email';

-- Attacker sends: admin' OR '1'='1' --
-- Result: Returns ALL users (bypasses auth!)

-- Or worse: '; DROP TABLE users; --
Enter fullscreen mode Exit fullscreen mode
// ✅ ALWAYS use parameterized queries
// With raw driver:
db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
db.prepare('SELECT * FROM products WHERE category = ? AND price < ?')
  .all(category, maxPrice);

// With ORM (Sequelize):
User.findByPk(id); // Safe by default
User.findOne({ where: { email } }); // Parameterized automatically

// With Knex:
knex('users').where({ id: userId }).first(); // Safe

// Even with parameterized queries, validate first:
if (!/^[a-f0-9]{24}$/.test(id)) {
  res.status(400).json({ error: 'Invalid ID format' });
  return;
}

// ⚠️ Dangerous: Dynamic table/column names can't be parameterized!
// If you MUST use dynamic identifiers:
const allowedTables = ['users', 'products', 'orders'];
if (!allowedTables.includes(table)) throw new Error('Invalid table');

const safeColumn = column.replace(/[^a-z_]/gi, ''); // Strip everything except letters/underscore
Enter fullscreen mode Exit fullscreen mode

4. XSS Prevention for APIs

// API XSS is different from web page XSS.
// The goal: prevent script injection that affects API consumers.

// Rule 1: Set correct Content-Type
app.get('/api/data', (req, res) => {
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
  res.json({ data: result }); // Browser won't render JSON as HTML
});

// Rule 2: Sanitize user content that will be displayed
import DOMPurify from 'dompurify'; // Server-side HTML sanitization

app.post('/api/comments', (req, res) => {
  const sanitizedContent = DOMPurify.sanitize(req.body.content);
  // Stores clean HTML, safe to render later
});

// Rule 3: If you MUST return HTML, set CSP headers
res.setHeader('Content-Security-Policy', 
  "default-src 'none'; script-src 'none'; style-src 'self'"
);

// Rule 4: Never reflect user input unsanitized
// ❌ Bad
app.get('/api/search', (req, res) => {
  res.send(`Results for: ${req.query.q}`); // Script injection!
});
// ✅ Good
app.get('/api/search', (req, res) => {
  res.json({ results: [...], query: escapeHtml(req.query.q) });
});
Enter fullscreen mode Exit fullscreen mode

5. Rate Limiting & Abuse Prevention

// Multi-layer rate limiting:

// Layer 1: Global (protects entire API)
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 1000, // 1000 requests per 15 min per IP
  standardHeaders: true,
}));

// Layer 2: Per-endpoint (stricter for sensitive endpoints)
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10, // Only 10 login attempts per 15 min
  skipSuccessfulRequests: true,
  keyGenerator: (req) => req.ip, // Rate limit by IP
});

// Layer 3: Per-user (after auth)
const userApiLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 30, // 30 requests per minute per user
  keyGenerator: (req) => req.user.id,
});

// Layer 4: Slow down brute force progressively
const slowDown = (maxAttempts) => {
  const attempts = new Map();
  return (req, res, next) => {
    const key = req.ip;
    const count = (attempts.get(key) || 0) + 1;
    attempts.set(key, count);

    // Exponential backoff delay
    if (count > maxAttempts) {
      const delayMs = Math.pow(2, count - maxAttempts) * 1000;
      return setTimeout(() => next(), delayMs);
    }
    next();
  };
};

// Response headers (always include!)
app.use((req, res, next) => {
  res.setHeader('X-RateLimit-Limit', '100');
  res.setHeader('X-RateLimit-Remaining', '95');
  res.setHeader('X-RateLimit-Reset', new Date(Date.now() + 900000).toISOString());
  next();
});
Enter fullscreen mode Exit fullscreen mode

6. CORS Configuration

// ❌ DANGEROUS: Allow everything
cors({ origin: '*' }) // Any website can call your API!

// ✅ Secure: Explicit allowlist
app.use(cors({
  origin: function (origin, callback) {
    const allowedOrigins = [
      'https://myapp.com',
      'https://www.myapp.com',
      'https://staging.myapp.com',
    ];

    // Allow in development
    if (process.env.NODE_ENV === 'development') {
      return callback(null, true);
    }

    // No origin header (mobile apps, curl, Postman)
    if (!origin) return callback(null, true);

    if (allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  credentials: true, // Allow cookies/auth headers
  maxAge: 86400, // Cache preflight for 24 hours
  optionsSuccessStatus: 200, // Older browsers expect 200, not 204
}));
Enter fullscreen mode Exit fullscreen mode

7. Security Headers Checklist

import helmet from 'helmet';
app.use(helmet({
  // Prevent clickjacking
  frameguard: { action: 'deny' }, // Or 'sameorigin' if you embed in iframes

  // Prevent MIME sniffing
  noSniff: true,

  // XSS protection (legacy but still useful for older browsers)
  xssFilter: true,

  // Referrer policy
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },

  // HSTS (force HTTPS)
  hsts: {
    maxAge: 31536000, // 1 year
    includeSubDomains: true,
    preload: true, // Submit to hstspreload.org
  },

  // DNS prefetching control
  dnsPrefetchControl: true,

  // Download protection (no automatic download)
  noSniff: true,

  // Cross-origin policy
  crossOriginResourcePolicy: { policy: 'same-origin' },
  crossOriginEmbedderPolicy: true,
}));

// Additional custom headers
app.use((req, res, next) => {
  // Remove server fingerprinting
  res.removeHeader('X-Powered-By'); // Don't reveal Express

  // Add security hint
  res.setHeader('X-Content-Type-Options', 'nosniff');

  next();
});
Enter fullscreen mode Exit fullscreen mode

8. Sensitive Data Protection

// ❌ Never log sensitive data
console.log('Login attempt:', { email, password }); // Password in logs!
logger.info('User created:', userData);              // May contain PII

// ✅ Safe logging
logger.info('Login attempt', { email, ip: req.ip }); // No password
logger.info('User created', { id: user.id });         // Minimal data

// ✅ Mask sensitive fields in responses
function sanitizeUser(user) {
  const { passwordHash, mfaSecret, recoveryCodes, ...safe } = user;
  return safe;
}

// ✅ Encrypt sensitive fields at rest
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;

function encrypt(text, masterKey) {
  const iv = randomBytes(IV_LENGTH);
  const key = scryptSync(masterKey, 'salt', 32);
  const cipher = createCipheriv(ALGORITHM, key, iv);
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const tag = cipher.getAuthTag();
  return iv.toString('hex') + ':' + tag.toString('hex') + ':' + encrypted;
}

function decrypt(encryptedText, masterKey) {
  const [ivHex, tagHex, encrypted] = encryptedText.split(':');
  const key = scryptSync(masterKey, 'salt', 32);
  const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
  decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
  return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8');
}

// ✅ Use environment variables for secrets (never hardcode!)
const DB_ENCRYPTION_KEY = process.env.DB_ENCRYPTION_KEY;
if (!DB_ENCRYPTION_KEY || DB_ENCRYPTION_KEY.length < 32) {
  console.error('FATAL: DB_ENCRYPTION_KEY must be >= 32 characters');
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

Pre-Deployment Security Checklist

□ All endpoints require authentication where appropriate?
□ Input validated AND sanitized on every endpoint?
□ Database uses parameterized queries everywhere?
□ Rate limiting on ALL endpoints (especially auth)?
□ CORS configured with explicit allowlist?
□ Security headers set (helmet.js)?
□ Secrets in environment variables (not in code)?
□ Error messages don't leak internals (stack traces, paths)?
□ HTTPS enforced everywhere?
□ Password hashing uses bcrypt/argon2 (not MD5/SHA)?
□ JWT tokens have short expiry + refresh token rotation?
□ File uploads validate type AND content (not just extension)?
□ SQL injection tested (with ' OR 1=1 -- patterns)?
□ XSS tested (with <script>alert(1)</script>)?
□ CSRF protection enabled (token or SameSite cookies)?
□ Dependencies audited (npm audit --json)?
□ Access controls checked (users can only access THEIR data)?
□ Admin endpoints have additional protection?
□ Logging doesn't capture passwords/tokens/PII?
□ API keys rotated regularly?
□ Web Application Firewall (WAF) in place?

Score yourself: Each ☑️ blocks an entire attack category.
Enter fullscreen mode Exit fullscreen mode

What's the most important security practice you follow? What's one you always forget?

Follow @armorbreak for more practical developer guides.

Top comments (1)

Collapse
 
circuit profile image
Rahul S

Worth flagging the CORS config's false sense of security. The if (!origin) return callback(null, true) pattern means any non-browser client — curl, Python requests, every bot framework, every attack tool — bypasses CORS entirely because they simply don't send an Origin header. CORS is a browser-cooperative mechanism enforced by the browser, not by the server. It protects the user's browser from being weaponized for cross-site requests, but it does nothing to protect the API from direct access. I've seen teams treat their CORS allowlist as if it were an access control layer, then get surprised when scrapers hit their endpoints without restriction. Authentication on every endpoint is the actual access control; CORS is a cross-origin policy for browsers specifically.