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 works. Now make sure it doesn't become the next data breach headline.

The Threat Model

Who attacks your API?
→ Bots scraping your data
→ Attackers trying SQL injection
→ Someone brute-forcing passwords
→ Malicious users manipulating other users' data
→ Anyone intercepting traffic (MITM)

Security isn't about eliminating all risk.
It's about making attacks expensive enough that they're not worth it.
Enter fullscreen mode Exit fullscreen mode

1. Authentication & Authorization

Never Roll Your Own Auth

// ❌ NEVER do this
function hashPassword(password) {
  return crypto.createHash('md5').update(password).digest('hex');
}
// MD5 is broken. SHA-1 is broken. Don't hash passwords yourself.

// ✅ Use bcrypt, argon2, or scrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;

async function hashPassword(password) {
  return await bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(password, hash) {
  return await bcrypt.compare(password, hash);
}

// bcrypt is slow by design — that's the point!
// Makes brute-force attacks prohibitively expensive.
Enter fullscreen mode Exit fullscreen mode

JWT Best Practices

const jwt = require('jsonwebtoken');

// Sign with strong secret + short expiry
function generateToken(user) {
  return jwt.sign(
    { 
      id: user.id,
      role: user.role,
      // Don't put sensitive data in JWT payload (it's base64 encoded!)
    },
    process.env.JWT_SECRET,           // At least 32 random chars
    { 
      expiresIn: '15m',               // Short-lived access token
      issuer: 'myapp.com',
      audience: 'myapp-api',
    }
  );
}

// Verify properly
function verifyToken(token) {
  try {
    return jwt.verify(token, process.env.JWT_SECRET, {
      issuer: 'myapp.com',
      audience: 'myapp-api',
    });
  } catch (err) {
    if (err.name === 'TokenExpiredError') throw new UnauthorizedError('Token expired');
    if (err.name === 'JsonWebTokenError') throw new UnauthorizedError('Invalid token');
    throw new UnauthorizedError('Authentication failed');
  }
}
Enter fullscreen mode Exit fullscreen mode

Middleware Pattern

// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function auth(req, res, next) {
  const header = req.headers.authorization;

  if (!header || !header.startsWith('Bearer ')) {
    throw new UnauthorizedError('Missing or invalid authorization header');
  }

  const token = header.split(' ')[1];
  const payload = verifyToken(token);

  req.user = payload; // Attach to request for downstream handlers
  next();
}

// Role-based access
function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user || !roles.includes(req.user.role)) {
      throw new ForbiddenError(`Requires role: ${roles.join(' or ')}`);
    }
    next();
  };
}

// Usage:
app.get('/api/admin', auth, requireRole('admin'), adminController.dashboard);
app.get('/api/users/me', auth, userController.getProfile);
Enter fullscreen mode Exit fullscreen mode

2. Input Validation

Validate EVERYTHING from the Client

// ❌ Trusting client input
app.get('/api/users/:id', async (req, res) => {
  const user = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`);
  // SQL INJECTION! User sends "1; DROP TABLE users--"
});

// ✅ Parameterized queries + validation
const { validate } = require('./middleware/validate');

app.get('/api/users/:id',
  validate({ id: [{ type: 'string', pattern: /^[a-f0-9]{24}$/ }] }), // MongoDB ObjectId format
  auth,
  async (req, res) => {
    const user = await db.collection('users').findOne({ 
      _id: new ObjectId(req.params.id) // Safe parameterized query
    });
    if (!user) throw new NotFoundError('User');
    res.json(sanitizeUser(user));
  }
);

// Validate body on write endpoints
app.post('/api/users',
  validate({
    email: [
      { required: true },
      { type: 'email' },
      { maxLength: 255 },
    ],
    name: [
      { required: true },
      { minLength: 2 },
      { maxLength: 100 },
      { pattern: /^[a-zA-Z\s'-]+$/, message: 'Only letters, spaces, hyphens, apostrophes' },
    ],
    password: [
      { required: true },
      { minLength: 8 },
      { custom: (val) => !val.includes(' ') ? null : 'Password cannot contain spaces' },
    ],
  }),
  userController.create
);
Enter fullscreen mode Exit fullscreen mode

Sanitize Output (Don't Leak Data)

// Remove sensitive fields before sending to client
function sanitizeUser(user) {
  const { passwordHash, mfaSecret, recoveryCodes, ...safe } = user;
  return safe;
}

// Also sanitize error messages in production:
// ❌ "Duplicate entry 'user@example.com' for key 'email'" → reveals DB structure
// ✅ "A user with this email already exists"
Enter fullscreen mode Exit fullscreen mode

3. Rate Limiting

// Simple in-memory rate limiter
const rateLimitMap = new Map();

function rateLimit(options = {}) {
  const {
    windowMs = 60_000,     // 1 minute window
    maxRequests = 100,     // Max requests per window
    keyGenerator = (req) => req.ip, // How to identify users
  } = options;

  return (req, res, next) => {
    const key = keyGenerator(req);
    const now = Date.now();

    if (!rateLimitMap.has(key)) {
      rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
    } else {
      const record = rateLimitMap.get(key);

      if (now > record.resetAt) {
        // Window reset
        record.count = 1;
        record.resetAt = now + windowMs;
      } else {
        record.count++;

        if (record.count > maxRequests) {
          const retryAfterSec = Math.ceil((record.resetAt - now) / 1000);
          res.set('Retry-After', retryAfterSec.toString());
          res.set('X-RateLimit-Limit', maxRequests.toString());
          res.set('X-RateLimit-Remaining', '0');
          res.set('X-RateLimit-Reset', record.resetAt.toString());

          throw new RateLimitError(retryAfterSec);
        }
      }
    }

    next();
  };
}

// Apply globally
app.use(rateLimit({ windowMs: 60_000, maxRequests: 100 }));

// Stricter for auth endpoints (brute-force protection)
app.post('/api/auth/login', 
  rateLimit({ windowMs: 15 * 60_000, maxRequests: 10 }), // 10 attempts per 15 min
  authController.login
);

app.post('/api/auth/register',
  rateLimit({ windowMs: 60 * 60_000, maxRequests: 3 }),   // 3 registrations per hour
  authController.register
);

// Per-user rate limit for API keys
const apiRateLimit = rateLimit({
  windowMs: 60_000,
  maxRequests: 50,
  keyGenerator: (req) => req.user?.id || req.ip,
});
Enter fullscreen mode Exit fullscreen mode

4. HTTPS & Headers

// helmet.js sets security headers automatically
const helmet = require('helmet');
app.use(helmet());

// What helmet sets:
// X-Content-Type-Options: nosniff        → Prevent MIME sniffing
// X-Frame-Options: SAMEORIGIN            → Prevent clickjacking
// X-XSS-Protection: 1; mode=block        → Basic XSS protection
// Referrer-Policy: strict-origin-when-cross-origin → Control referrer info
// Content-Security-Policy: default-src 'self' → Restrict resource loading

// Custom CSP for apps that need external resources
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'", 'cdn.example.com'],
    styleSrc: ["'self'", "'unsafe-inline'", 'fonts.googleapis.com'],
    fontSrc: ["'self'", 'fonts.gstatic.com'],
    imgSrc: ["'self'", 'data:', 'https:'],
    connectSrc: ["'self'", 'api.example.com'],
  },
}));

// Force HTTPS in production
if (process.env.NODE_ENV === 'production') {
  app.use((req, res, next) => {
    if (req.header('x-forwarded-proto') !== 'https') {
      return res.redirect(301, `https://${req.header('host')}${req.url}`);
    }
    next();
  });
}
Enter fullscreen mode Exit fullscreen mode

5. CORS Configuration

const cors = require('cors');

// ❌ DANGEROUS: Allow everything
app.use(cors()); // Allows ALL origins!

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

    // Allow no origin for server-to-server (Postman, curl, etc.)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  credentials: true,              // Allow cookies/auth headers
  maxAge: 86400,                  // Pre-flight cache for 24h
  optionsSuccessStatus: 204,      // No content for pre-flight
}));
Enter fullscreen mode Exit fullscreen mode

6. Common Vulnerabilities & How to Fix Them

SQL Injection

-- ❌ String concatenation (vulnerable)
SELECT * FROM users WHERE id = '$userId'

-- ✅ Parameterized queries (safe)
SELECT * FROM users WHERE id = ?
-- Or: SELECT * FROM users WHERE id = $1
Enter fullscreen mode Exit fullscreen mode

XSS (Cross-Site Scripting)

// ❌ Rendering raw HTML
res.send(`<div>${userInput}</div>`);

// ✅ Escape output
const escapeHtml = (str) => str
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');
res.send(`<div>${escapeHtml(userInput)}</div>`);

// Better: Use a template engine that auto-escapes (EJS, Pug, Handlebars)
// For APIs returning JSON: XSS risk is lower but still possible via JSONP
Enter fullscreen mode Exit fullscreen mode

CSRF (Cross-Site Request Forgery)

// For cookie-based auth, use CSRF tokens:
const csrf = require('csurf');
app.use(csrf({ cookie: true }));

// Include token in forms and AJAX requests:
app.get('/api/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// Client must send this token with every mutating request:
fetch('/api/update', {
  method: 'POST',
  headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' },
  body: JSON.stringify(data),
});
// Note: If using JWT (not cookies), CSRF is less of a concern
Enter fullscreen mode Exit fullscreen mode

IDOR (Insecure Direct Object Reference)

// ❌ Anyone can access any user's data
app.get('/api/orders/:id', auth, async (req, res) => {
  const order = await Order.findById(req.params.id); // Any ID!
  res.json(order);
});

// ✅ Enforce ownership
app.get('/api/orders/:id', auth, async (req, res) => {
  const order = await Order.findOne({
    _id: req.params.id,
    userId: req.user.id, // MUST match authenticated user!
  });
  if (!order) throw new NotFoundError('Order');
  res.json(order);
});
Enter fullscreen mode Exit fullscreen mode

Mass Assignment

// ❌ Accept entire body blindly
app.put('/api/users/:id', async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, req.body);
  // Attacker sends { role: 'admin' } → instant admin!
});

// ✅ Whitelist allowed fields
const ALLOWED_UPDATES = ['name', 'email', 'avatar'];

app.put('/api/users/:id', async (req, res) => {
  const updates = {};
  for (const field of ALLOWED_UPDATES) {
    if (req.body[field] !== undefined) {
      updates[field] = req.body[field];
    }
  }
  const user = await User.findByIdAndUpdate(req.params.id, updates);
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

7. Logging & Monitoring

// Log security-relevant events
const securityLog = {
  login_success: (req, user) => ({
    event: 'auth.login.success',
    userId: user.id,
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: new Date().toISOString(),
  }),

  login_failure: (req, reason) => ({
    event: 'auth.login.failure',
    ip: req.ip,
    reason, // 'bad_password', 'account_locked', etc.
    timestamp: new Date().toISOString(),
  }),

  permission_denied: (req, resource) => ({
    event: 'auth.denied',
    userId: req.user?.id,
    ip: req.ip,
    resource,
    method: req.method,
    path: req.path,
    timestamp: new Date().toISOString(),
  }),

  rate_limit_exceeded: (req) => ({
    event: 'security.rate_limit',
    ip: req.ip,
    path: req.path,
    timestamp: new Date().toISOString(),
  }),
};

// Send alerts for suspicious activity
function checkForAnomalies(logEntry) {
  const ipFailures = countRecentFailures(logEntry.ip);

  if (ipFailures >= 10) {
    alertSecurityTeam({
      level: 'warning',
      type: 'brute_force_attempt',
      ip: logEntry.ip,
      failures: ipFailures,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Security Checklist

Before deploying any API:

□ All endpoints require authentication (except public ones)
□ Passwords hashed with bcrypt/argon2 (never plaintext)
□ JWT secrets are ≥ 32 chars and stored in env vars
□ Token expiry is short (≤ 15min for access tokens)
□ All inputs validated AND sanitized
□ Parameterized queries (never string concatenation)
□ Rate limiting on all endpoints (stricter on auth)
□ CORS configured to specific origins only
□ Security headers set (helmet.js)
□ HTTPS enforced in production
□ Ownership checks on all user-scoped resources
□ Field whitelisting on update endpoints
□ No sensitive data in error messages (production)
□ Security logging for auth events
□ Dependencies up to date (npm audit)
□ No secrets in code or git history (.env gitignored)
Enter fullscreen mode Exit fullscreen mode

What's the most important security practice you follow?

Follow @armorbreak for more practical developer guides.

Top comments (0)