Back to Blog

API Security Best Practices

Essential security patterns for protecting your APIs from common attacks, with practical implementation examples for development teams.

Why API Security Deserves Special Attention

APIs are the front door to your data. Every mobile app, single-page application, third-party integration, and internal microservice communicates through APIs. A vulnerability in your API exposes not just one interface, but potentially every client that connects to it.

The OWASP API Security Top 10 lists the most common API vulnerabilities: broken object-level authorization, broken authentication, excessive data exposure, lack of rate limiting, and more. These are not theoretical risks. They are the attack patterns used in real breaches every week.

This guide covers practical security patterns that every development team should implement.

Authentication

Token-Based Authentication

Session cookies work for traditional web applications, but APIs serving mobile apps, SPAs, and third-party clients need token-based authentication. JSON Web Tokens (JWT) are the standard:

// Token structure
// Header: algorithm and token type
// Payload: claims (user data, permissions, expiration)
// Signature: cryptographic verification

const token = jwt.sign(
  {
    sub: user.id,
    email: user.email,
    roles: ['admin', 'editor'],
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + (60 * 15) // 15 minutes
  },
  process.env.JWT_SECRET,
  { algorithm: 'HS256' }
);

Critical JWT practices:

  • Short expiration: Access tokens should expire in 15 to 30 minutes
  • Refresh tokens: Use longer-lived refresh tokens stored securely to obtain new access tokens
  • Strong secrets: Use at least 256-bit keys. Never hardcode them.
  • Algorithm validation: Always specify the expected algorithm to prevent algorithm confusion attacks

OAuth 2.0 for Third-Party Access

When external applications need access to your API, use OAuth 2.0 with the authorization code flow:

1. Client redirects user to authorization server
2. User authenticates and grants permissions
3. Authorization server returns authorization code
4. Client exchanges code for access token (server-to-server)
5. Client uses access token to call API

Never use the implicit flow for new applications. It exposes tokens in URLs and browser history.

API Keys for Service-to-Service

For internal service communication or trusted third-party integrations:

  • Generate cryptographically random API keys (minimum 32 bytes)
  • Hash stored API keys (never store them in plain text)
  • Support key rotation without downtime
  • Bind keys to specific IP ranges or services when possible
  • Log all API key usage for audit purposes

Authorization

Object-Level Authorization

The most common API vulnerability: checking whether a user is authenticated but not whether they are authorized to access the specific resource.

// WRONG: Only checks authentication
app.get('/api/orders/:id', authenticate, async (req, res) => {
  const order = await Order.findById(req.params.id);
  res.json(order); // Any authenticated user can see any order
});

// CORRECT: Checks object-level authorization
app.get('/api/orders/:id', authenticate, async (req, res) => {
  const order = await Order.findById(req.params.id);
  
  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }
  
  if (order.userId !== req.user.id && !req.user.roles.includes('admin')) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  res.json(order);
});

Every endpoint that accesses a specific resource must verify that the authenticated user has permission to access that particular resource.

Function-Level Authorization

Ensure that admin-only operations are actually restricted to admins:

function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.some(role => req.user.roles.includes(role))) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

app.delete('/api/users/:id', authenticate, requireRole('admin'), async (req, res) => {
  // Only admins reach this code
});

Field-Level Access Control

Not every user should see every field in a response:

function filterUserResponse(user, requestingUser) {
  const publicFields = {
    id: user.id,
    name: user.name,
    department: user.department
  };

  if (requestingUser.roles.includes('hr') || requestingUser.id === user.id) {
    return {
      ...publicFields,
      email: user.email,
      phone: user.phone,
      salary: requestingUser.roles.includes('hr') ? user.salary : undefined
    };
  }

  return publicFields;
}

Input Validation

Validate Everything

Never trust client input. Validate type, format, length, and range for every parameter:

const { z } = require('zod');

const createOrderSchema = z.object({
  customerId: z.string().uuid(),
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().positive().max(10000),
    unitPrice: z.number().positive().max(999999999)
  })).min(1).max(100),
  deliveryDate: z.string().datetime(),
  notes: z.string().max(500).optional()
});

app.post('/api/orders', authenticate, async (req, res) => {
  const result = createOrderSchema.safeParse(req.body);
  
  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.issues
    });
  }
  
  // Process validated data
  const order = await createOrder(result.data);
  res.status(201).json(order);
});

SQL Injection Prevention

Always use parameterized queries. Never concatenate user input into SQL strings:

// DANGEROUS: SQL injection vulnerability
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;

// SAFE: Parameterized query
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [req.body.email]);

Rate Limiting

Protect Against Abuse

Without rate limiting, a single client can overwhelm your API with requests, whether through intentional attack or buggy code:

const rateLimit = require('express-rate-limit');

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

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

app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);

Graduated Rate Limiting

Apply different limits based on authentication status and endpoint sensitivity:

Endpoint TypeAnonymousAuthenticatedPremium
Public read30/min100/min500/min
Authenticated readN/A60/min300/min
Write operationsN/A20/min100/min
Authentication5/15minN/AN/A
Admin operationsN/A30/min30/min

Response Security

Minimize Data Exposure

Return only the fields the client needs. Never return entire database records:

// BAD: Returns everything including sensitive fields
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user); // Includes password hash, internal notes, etc.
});

// GOOD: Returns only necessary fields
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id)
    .select('id name email department createdAt');
  res.json(user);
});

Security Headers

Set appropriate HTTP security headers on all API responses:

app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Cache-Control', 'no-store');
  res.setHeader('Content-Security-Policy', "default-src 'none'");
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  next();
});

Error Handling

Never expose internal details in error responses:

// BAD: Leaks implementation details
res.status(500).json({
  error: 'PostgreSQL error: relation "users" does not exist',
  stack: error.stack
});

// GOOD: Generic message with correlation ID for debugging
const correlationId = crypto.randomUUID();
logger.error({ correlationId, error: error.message, stack: error.stack });
res.status(500).json({
  error: 'An internal error occurred',
  correlationId: correlationId
});

Logging and Monitoring

What to Log

Every API request should generate a log entry containing:

  • Timestamp
  • Request method and path
  • Client IP address and user agent
  • Authenticated user ID (if applicable)
  • Response status code
  • Response time
  • Request correlation ID

What to Alert On

Set up automated alerts for:

  • Authentication failure spikes (potential brute force)
  • Rate limit violations (potential attack or misbehaving client)
  • 403 response spikes (potential authorization probe)
  • 500 error rate increase (potential exploit or system issue)
  • Unusual traffic patterns (volume, geographic origin, time of day)

Security Checklist for Every API

Before deploying any API endpoint:

  • Authentication required for non-public endpoints
  • Object-level authorization checks on every resource access
  • Input validation for all parameters (type, format, length, range)
  • Rate limiting configured appropriately
  • Response fields minimized to only necessary data
  • Error messages do not leak internal details
  • Security headers set
  • HTTPS enforced (no HTTP)
  • Logging captures security-relevant events
  • SQL injection prevention through parameterized queries

API security is not a one-time project. It is a continuous practice that must be part of every code review, every deployment, and every architectural decision. The cost of getting it right is small. The cost of getting it wrong can be catastrophic.

Baca dalam Bahasa Indonesia Versi Indonesia