API Security Best Practices: Authentication, Validation, and Rate Limiting

Share

TL;DR

The #1 API security best practice is defense in depth. Authenticate every request, authorize access to specific resources, validate all inputs, rate limit to prevent abuse, and never expose sensitive data in responses. These practices prevent 88% of API security incidents. Total time to implement: ~36 minutes.

"APIs are the front doors, back doors, and windows of modern applications. Lock every single one of them."

The API Security Checklist

Every API endpoint should:

  • Verify authentication (who is the user?)
  • Check authorization (can they access this resource?)
  • Validate input data (is the request well-formed?)
  • Sanitize output (does the response contain sensitive data?)
  • Log the request (for auditing and debugging)
  • Handle errors gracefully (without leaking information)

Best Practice 1: Authenticate Every Request 5 min

Never trust that a request is authenticated. Verify on every call:

Express middleware for authentication
import jwt from 'jsonwebtoken';

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

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

  const token = authHeader.substring(7);

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Apply to protected routes
app.use('/api/protected', authenticate);

Best Practice 2: Implement Authorization 5 min

Authentication is not enough. Verify the user can access the specific resource:

Resource-level authorization
app.get('/api/posts/:id', authenticate, async (req, res) => {
  const post = await db.post.findUnique({
    where: { id: req.params.id }
  });

  if (!post) {
    return res.status(404).json({ error: 'Not found' });
  }

  // Authorization: Check if user can access this post
  if (post.authorId !== req.user.id && !post.isPublished) {
    return res.status(403).json({ error: 'Access denied' });
  }

  res.json(post);
});

app.put('/api/posts/:id', authenticate, async (req, res) => {
  const post = await db.post.findUnique({
    where: { id: req.params.id }
  });

  if (!post) {
    return res.status(404).json({ error: 'Not found' });
  }

  // Only author can update
  if (post.authorId !== req.user.id) {
    return res.status(403).json({ error: 'Access denied' });
  }

  // Proceed with update...
});

Best Practice 3: Validate All Input 5 min

Never trust client data. Validate everything:

Input validation with Zod
import { z } from 'zod';

const createPostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().max(50000),
  tags: z.array(z.string()).max(10).optional(),
});

app.post('/api/posts', authenticate, async (req, res) => {
  // Validate input
  const result = createPostSchema.safeParse(req.body);

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

  // Use validated data
  const post = await db.post.create({
    data: {
      ...result.data,
      authorId: req.user.id, // Never use client-provided user ID
    },
  });

  res.status(201).json(post);
});

Best Practice 4: Use Parameterized Queries 3 min

Prevent SQL injection by using parameterized queries or ORMs:

SQL injection prevention
// WRONG: SQL injection vulnerability
const query = `SELECT * FROM users WHERE id = ${userId}`;

// CORRECT: Parameterized query
const result = await db.query(
  'SELECT * FROM users WHERE id = $1',
  [userId]
);

// ALSO CORRECT: Using an ORM like Prisma
const user = await prisma.user.findUnique({
  where: { id: userId }
});

Best Practice 5: Rate Limit Your API 5 min

Prevent abuse and brute force attacks with rate limiting:

Rate limiting with express-rate-limit
import rateLimit from 'express-rate-limit';

// General API rate limit
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: { error: 'Too many requests' },
  standardHeaders: true,
});

// Stricter limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // Only 5 attempts
  message: { error: 'Too many login attempts' },
});

// Even stricter for password reset
const passwordResetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3,
  message: { error: 'Too many password reset requests' },
});

app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/reset-password', passwordResetLimiter);

Best Practice 6: Handle Errors Safely 5 min

Error messages should not expose internal details:

Safe error handling
// Global error handler
app.use((err, req, res, next) => {
  // Log full error for debugging
  console.error('API Error:', {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    userId: req.user?.id,
  });

  // Send generic message to client
  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: 'Invalid request data' });
  }

  if (err.name === 'UnauthorizedError') {
    return res.status(401).json({ error: 'Authentication required' });
  }

  // Generic error for unexpected issues
  res.status(500).json({ error: 'An unexpected error occurred' });
});

Never expose: Stack traces, database errors, file paths, or internal system details in API responses. These help attackers understand your system.

Best Practice 7: Sanitize Response Data 5 min

Remove sensitive fields before sending responses:

Response sanitization
// Define what fields to expose
function sanitizeUser(user) {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    createdAt: user.createdAt,
    // Never include: password, tokens, internal IDs
  };
}

app.get('/api/users/:id', authenticate, async (req, res) => {
  const user = await db.user.findUnique({
    where: { id: req.params.id }
  });

  if (!user) {
    return res.status(404).json({ error: 'Not found' });
  }

  // Sanitize before sending
  res.json(sanitizeUser(user));
});

// Or use Prisma select to only fetch needed fields
const user = await prisma.user.findUnique({
  where: { id: userId },
  select: {
    id: true,
    name: true,
    email: true,
    // password is never selected
  },
});

Best Practice 8: Use HTTPS Only 3 min

All API traffic should be encrypted:

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

// Add security headers
app.use((req, res, next) => {
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  next();
});

Common API Security Mistakes

MistakeImpactPrevention
Missing authenticationUnauthorized accessAuth middleware on all routes
No authorization checksData from other usersVerify resource ownership
Trusting client user IDImpersonationUse server-side session
Verbose error messagesInformation disclosureGeneric client messages
No rate limitingBrute force, DoSImplement rate limits

External Resources: For comprehensive API security guidance, see the OWASP API Security Top 10 and the REST Security Cheat Sheet . These resources provide industry-standard security recommendations.

Should I use JWT or sessions for API auth?

Both can be secure. JWTs work well for stateless APIs and microservices. Sessions (stored server-side) offer easier revocation. For SPAs, consider HttpOnly cookies with session tokens for better XSS protection.

How strict should rate limiting be?

It depends on your use case. Public APIs: 100-1000 requests/hour. Authenticated APIs: higher limits per user. Auth endpoints: very strict (5-10 attempts/15 minutes). Start strict and relax based on legitimate usage patterns.

Should I validate on client and server?

Yes, both. Client validation improves user experience with instant feedback. Server validation is required for security since client validation can be bypassed. Never skip server-side validation.

How do I handle API versioning securely?

Use URL versioning (/api/v1/) or headers. When deprecating versions, give notice and maintain security patches until end-of-life. Never expose internal version numbers that reveal your technology stack.

Verify Your API Security

Scan your API endpoints for security issues and vulnerabilities.

Start Free Scan
Best Practices

API Security Best Practices: Authentication, Validation, and Rate Limiting