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

TL;DR

The #1 API security best practice is defense in depth. Authenticate every request, authorize access to the specific resource, validate all inputs, rate limit to prevent abuse, and keep sensitive fields out of responses. None of those is enough on its own, which is the point. 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 assume a request is authenticated just because it reached a protected route. Check 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

Knowing who someone is doesn't tell you what they're allowed to touch. That's a second check, and it's the one that gets skipped.

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

Client data is a suggestion. Validate it.

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

Parameterized queries and ORMs both keep user input out of the SQL itself, which is the entire defense against injection.

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

Rate limits are what stop one script from trying ten thousand passwords overnight.

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

An error message is a response to an attacker too. Say less.

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

Fetching a whole user row and returning it is how password hashes end up in a JSON response. Pick the fields deliberately.

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

Everything on the wire should be encrypted, including the redirect that gets you there.

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. Both are industry-standard references.

Should I use JWT or sessions for API auth?

Both can be secure. JWTs suit stateless APIs and microservices. Server-side sessions are easier to revoke, which matters more than it sounds the day you need to kick someone out right now. For SPAs, HttpOnly cookies with session tokens give you 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 is for the user, giving instant feedback. Server validation is for security, because anything running on the client can be bypassed. Never skip the server side.

How do I handle API versioning securely?

Use URL versioning (/api/v1/) or headers. When you deprecate a version, give notice and keep patching it until end-of-life. Don't expose internal build numbers that give away your stack.

Further Reading

Put these practices into action with our step-by-step guides.

Verify Your API Security

Scan your API endpoints for security issues and vulnerabilities.

Best Practices

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