Back to Blog API Security

API Security Best Practices: Protecting Your Microservices Architecture

16 min read

In 2019, a security researcher discovered that Facebook's "View As" feature had a vulnerability that allowed attackers to steal access tokens for any account. The bug wasn't in the authentication system itself—it was in an API endpoint that generated tokens with too many permissions. Fifty million accounts were compromised. The culprit wasn't weak passwords or phishing—it was a broken API.

APIs have become the primary attack surface for modern applications. A decade ago, attackers targeted web forms and SQL databases. Today, they probe API endpoints. Every mobile app, single-page application, microservice, and third-party integration communicates through APIs. The average enterprise exposes hundreds or thousands of API endpoints, each one a potential entry point for attackers.

The stakes are high: APIs often provide direct access to databases, payment systems, and personal data. A vulnerability in a web page might expose what's on that page. A vulnerability in an API can expose everything.

Understanding the API Attack Surface

Before diving into defenses, it's worth understanding why APIs are such attractive targets and how attackers approach them.

APIs are designed to be consumed programmatically. Unlike web pages with CAPTCHAs and rate-limited forms, APIs are built for high-volume automated access. This design makes them efficient—and makes attacks scalable. An attacker can probe thousands of endpoints in the time it would take to manually test a few web pages.

API documentation is a roadmap for attackers. Swagger docs, GraphQL introspection, and API references tell attackers exactly which endpoints exist, what parameters they accept, and what responses to expect. This is the opposite of security through obscurity—it's security despite full transparency.

Microservices multiply the attack surface. A monolithic application might have one API. A microservices architecture might have fifty internal services, each with its own API, communicating across the network. An attacker who compromises one service can explore internal APIs that were never designed for external exposure.

The OWASP API Security Top 10: Real-World Risks

OWASP's API Security Top 10 catalogs the most critical API vulnerabilities. But the list is more useful when you understand how these vulnerabilities actually get exploited.

Broken Object Level Authorization (BOLA): The Silent Killer

BOLA is the most common and most dangerous API vulnerability. It occurs when an API doesn't verify that the authenticated user has permission to access the requested resource.

Consider an e-commerce API endpoint:

GET /api/orders/12345

If the API simply looks up order 12345 and returns it—without checking whether the authenticated user owns that order—an attacker can enumerate order IDs and access any customer's order history. Change 12345 to 12346, 12347, 12348... and you've stolen thousands of customers' purchase data, addresses, and payment details.

This isn't theoretical. In 2021, researchers found that Experian's API allowed anyone to access anyone else's credit score by simply changing the user ID in the API request. In 2020, a BOLA vulnerability in a Starbucks API exposed 100 million customer records.

The fix seems obvious: check authorization. But in practice, developers often forget because the user is already authenticated. Authentication proves who you are; authorization proves what you can access. They're different, and both are required.

Broken Authentication: Not Just Weak Passwords

API authentication failures go beyond guessable passwords. Common issues include:

  • Tokens that don't expire: Stolen tokens can be used indefinitely
  • Tokens with excessive permissions: The Facebook bug mentioned earlier
  • Weak token generation: Predictable tokens can be guessed
  • Missing authentication: Internal APIs exposed externally without auth
  • Credential stuffing: No protection against automated login attempts

Unrestricted Resource Consumption: The Denial of Wallet

APIs without rate limits are vulnerable to abuse: credential stuffing attacks, data scraping, and denial-of-service. But there's a newer variant—the "denial of wallet" attack. If your API calls expensive cloud services (AI inference, database queries, third-party APIs) without limits, an attacker can bankrupt you by hammering endpoints that cost money per request.

Authentication: Proving Identity

Modern API authentication has largely standardized on OAuth 2.0 and OpenID Connect. These protocols are complex because they handle complex scenarios—mobile apps, single-page applications, server-to-server communication, and third-party integrations all have different security requirements.

OAuth 2.0 with PKCE: The Modern Standard

The Authorization Code flow with PKCE (Proof Key for Code Exchange) is the recommended approach for most applications. PKCE prevents authorization code interception attacks that plagued earlier OAuth implementations.

Here's a secure OAuth configuration that addresses common vulnerabilities:

// OAuth 2.0 security configuration
// These settings prevent the most common OAuth attacks
const authConfig = {
  // REQUIRED: Use Authorization Code flow with PKCE
  // This prevents code interception attacks on mobile and SPA apps
  responseType: 'code',
  usePKCE: true,
  codeChallengeMethod: 'S256',  // SHA-256, never use 'plain'

  // Token lifetimes: Short access tokens, longer refresh tokens
  // If an access token leaks, damage is limited to its lifetime
  accessTokenLifetime: 900,      // 15 minutes
  refreshTokenLifetime: 86400,   // 24 hours
  refreshTokenRotation: true,    // Issue new refresh token on each use

  // CRITICAL: Strict redirect URI validation
  // Attackers try to redirect tokens to their servers
  allowedRedirectUris: [
    'https://app.company.com/callback',
    'https://staging.company.com/callback'
    // No wildcards! Each URI must be exact
  ],
  // Reject if redirect_uri doesn't exactly match
  strictRedirectValidation: true,

  // Require client authentication at token endpoint
  // Prevents tokens being issued to unknown clients
  requireClientAuthentication: true,
  tokenEndpointAuthMethod: 'private_key_jwt'  // Most secure method
};

Token validation is equally critical. A JWT that isn't properly validated is worse than no authentication at all—it creates false confidence. Here's what complete validation looks like:

// JWT validation middleware
// EVERY claim must be verified - partial validation is dangerous
const validateToken = async (req, res, next) => {
  const authHeader = req.headers.authorization;

  // Require Bearer token format
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed token' });
  }

  const token = authHeader.split(' ')[1];

  try {
    // Verify ALL security properties:
    const decoded = await jose.jwtVerify(token, publicKey, {
      // 1. Signature: Was this token issued by our auth server?
      algorithms: ['RS256'],  // Explicitly specify - never accept 'none'

      // 2. Issuer: Did the right auth server issue this?
      issuer: 'https://auth.company.com',

      // 3. Audience: Was this token meant for this API?
      audience: 'api.company.com',

      // 4. Expiration: Is the token still valid?
      // jose library checks this automatically

      // 5. Not-before: Is the token active yet?
      // jose library checks this automatically
    });

    // Attach user info to request for authorization checks
    req.user = {
      id: decoded.payload.sub,
      email: decoded.payload.email,
      roles: decoded.payload.roles || [],
      scopes: decoded.payload.scope?.split(' ') || []
    };

    next();
  } catch (error) {
    // Don't leak error details - they help attackers
    console.error('Token validation failed:', error.code);
    return res.status(401).json({ error: 'Invalid token' });
  }
};

API Keys: Service-to-Service Authentication

API keys are appropriate for server-to-server communication where OAuth flows are impractical. But API keys have unique security challenges: they're long-lived, often shared, and easy to leak.

// Secure API key validation
// Addresses common API key vulnerabilities
class APIKeyValidator {
  constructor(keyStore) {
    this.keyStore = keyStore;  // Secure database for key metadata
  }

  async validate(apiKey) {
    // NEVER store raw API keys - store hashes
    // If your key database leaks, attackers still can't use the keys
    const keyHash = crypto
      .createHash('sha256')
      .update(apiKey)
      .digest('hex');

    const keyRecord = await this.keyStore.findByHash(keyHash);

    if (!keyRecord) {
      // Timing attack protection: same response time for all failures
      await this.artificialDelay();
      return { valid: false, reason: 'invalid_key' };
    }

    // Check if key has been revoked
    if (keyRecord.revokedAt) {
      return { valid: false, reason: 'revoked' };
    }

    // Check expiration
    if (keyRecord.expiresAt && keyRecord.expiresAt < Date.now()) {
      return { valid: false, reason: 'expired' };
    }

    // Check if key is rate limited
    const rateLimitKey = `ratelimit:${keyRecord.id}`;
    if (await this.rateLimiter.isExceeded(rateLimitKey)) {
      return { valid: false, reason: 'rate_limited' };
    }

    // Log every API key usage for audit
    await this.auditLog.record({
      keyId: keyRecord.id,
      clientId: keyRecord.clientId,
      timestamp: new Date(),
      ip: this.requestContext.ip
    });

    return {
      valid: true,
      clientId: keyRecord.clientId,
      scopes: keyRecord.scopes,
      rateLimitTier: keyRecord.rateLimitTier
    };
  }
}

Authorization: Proving Permission

Authentication tells you who someone is. Authorization tells you what they can do. The distinction matters because the most common API vulnerability—BOLA—happens when developers authenticate users but forget to authorize specific actions.

Preventing BOLA: Authorization on Every Access

The pattern is simple but must be followed religiously: every time you access a resource, verify the user has permission to access that specific resource.

// Authorization service
// Centralizes permission checks so they're never forgotten
class Authorizer {
  async canAccess(user, resource, action) {
    // Level 1: Resource ownership
    // Most common case - users own their own data
    if (resource.ownerId === user.id) {
      return true;
    }

    // Level 2: Explicit grants
    // User A explicitly shared with User B
    const grant = await this.db.findGrant({
      granteeId: user.id,
      resourceId: resource.id,
      action: action
    });

    if (grant && !this.isExpired(grant)) {
      return true;
    }

    // Level 3: Role-based access
    // Admins can access certain resource types
    if (user.roles.includes('admin') && this.adminCanAccess(resource, action)) {
      return true;
    }

    // Level 4: Organization membership
    // Users can access resources in their organization
    if (resource.organizationId &&
        user.organizationIds?.includes(resource.organizationId)) {
      return true;
    }

    // Default: Deny
    return false;
  }
}

// Usage pattern: Authorization happens INSIDE the endpoint
// Not in middleware - because we need the specific resource
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  // Step 1: Fetch the resource
  const order = await Order.findById(req.params.orderId);

  if (!order) {
    // Don't leak whether the order exists
    // Same error whether it doesn't exist or isn't authorized
    return res.status(404).json({ error: 'Order not found' });
  }

  // Step 2: ALWAYS check authorization
  // This is where BOLA vulnerabilities happen when forgotten
  if (!await authorizer.canAccess(req.user, order, 'read')) {
    // Log the attempt - might be an attack
    securityLog.warn('Unauthorized access attempt', {
      userId: req.user.id,
      resourceId: order.id,
      action: 'read'
    });
    // Return 404, not 403 - don't confirm the resource exists
    return res.status(404).json({ error: 'Order not found' });
  }

  // Step 3: Only now return the resource
  res.json(order);
});

Note the security practice of returning 404 instead of 403 for unauthorized access. A 403 tells attackers "this order exists, you just can't see it." A 404 reveals nothing.

Input Validation: Trust Nothing

Every piece of data from outside your system is potentially malicious: URL parameters, request bodies, headers, cookies, and even data from your own database (which might have been compromised). Validation is the first line of defense against injection attacks, data corruption, and business logic abuse.

Schema Validation with Type Safety

Modern validation libraries like Zod, Yup, or JSON Schema provide both runtime validation and TypeScript type inference. This means your validation rules and your type system stay in sync:

// Using Zod for runtime validation with type inference
import { z } from 'zod';

// Define the schema once - get types AND validation
const createOrderSchema = z.object({
  // String validation with transformations
  productId: z.string()
    .uuid()                              // Must be valid UUID format
    .describe('Product identifier'),

  // Numeric validation with business rules
  quantity: z.number()
    .int()                               // No fractional quantities
    .positive()                          // Can't order zero or negative
    .max(100)                           // Business limit per order
    .describe('Quantity to order'),

  // Shipping address with nested validation
  shippingAddress: z.object({
    street: z.string().min(5).max(200),
    city: z.string().min(1).max(100),
    state: z.string().length(2),        // State code
    zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
    country: z.enum(['US', 'CA'])       // Only supported countries
  }),

  // Optional fields with defaults
  expeditedShipping: z.boolean().default(false),
  giftMessage: z.string().max(500).optional()
});

// TypeScript infers this type automatically from the schema
type CreateOrderInput = z.infer;

// Validation middleware factory
const validate = (schema: z.ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      // Return structured errors for API clients
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.issues.map(issue => ({
          field: issue.path.join('.'),
          message: issue.message
        }))
      });
    }

    // Replace req.body with validated data
    // Zod strips unknown fields, preventing mass assignment
    req.body = result.data;
    next();
  };
};

// Apply validation before handler
app.post('/api/orders',
  authenticate,
  validate(createOrderSchema),
  createOrderHandler
);

SQL Injection: Still a Threat

SQL injection has been understood for over 25 years, yet it still appears in the OWASP Top 10. The defense is simple—parameterized queries—but developers still make mistakes, especially with dynamic query building:

// SQL Injection: What NOT to do
// These patterns are vulnerable even with modern frameworks

// BAD: String concatenation (obvious vulnerability)
const badQuery1 = `SELECT * FROM users WHERE id = ${userId}`;

// BAD: Template literals (same problem, better syntax)
const badQuery2 = `SELECT * FROM products WHERE name = '${searchTerm}'`;

// BAD: Dynamic column names (often overlooked)
const badQuery3 = `SELECT * FROM orders ORDER BY ${sortColumn}`;

// GOOD: Parameterized queries
const result = await db.query(
  'SELECT * FROM users WHERE id = $1 AND status = $2',
  [userId, 'active']
);

// GOOD: ORM with proper escaping (Prisma example)
const user = await prisma.user.findUnique({
  where: { id: userId }
});

// CAREFUL: Dynamic columns need allowlisting
const allowedSortColumns = ['created_at', 'name', 'price'];
if (!allowedSortColumns.includes(sortColumn)) {
  throw new Error('Invalid sort column');
}
const result = await db.query(
  `SELECT * FROM products ORDER BY ${sortColumn} LIMIT $1`,
  [limit]  // Dynamic values still use parameters
);

Rate Limiting: Protecting Resources

Without rate limiting, your API is vulnerable to credential stuffing, scraping, DoS attacks, and "denial of wallet" attacks against metered cloud services. Effective rate limiting requires multiple layers:

// Multi-layer rate limiting strategy
// Each layer catches different attack patterns

const rateLimitConfig = {
  // Layer 1: Global rate limit
  // Protects against floods from botnets
  global: {
    windowMs: 60 * 1000,           // 1 minute window
    max: 10000,                    // 10K requests per minute total
    message: 'Service temporarily unavailable'
  },

  // Layer 2: Per-IP rate limit
  // Catches automated attacks from single sources
  perIP: {
    windowMs: 60 * 1000,
    max: 100,                      // 100 requests per minute per IP
    keyGenerator: (req) => req.ip
  },

  // Layer 3: Per-user rate limit
  // Prevents abuse by authenticated users
  perUser: {
    windowMs: 60 * 1000,
    max: 300,                      // Higher limit for authenticated users
    keyGenerator: (req) => req.user?.id || req.ip,
    skipFailedRequests: false      // Count failed requests too
  },

  // Layer 4: Per-endpoint rate limits
  // Sensitive endpoints get stricter limits
  sensitive: {
    '/api/auth/login': {
      windowMs: 15 * 60 * 1000,    // 15 minute window
      max: 5,                      // 5 login attempts
      message: 'Too many login attempts, please try again later'
    },
    '/api/auth/password-reset': {
      windowMs: 60 * 60 * 1000,    // 1 hour window
      max: 3,                      // 3 reset requests
      message: 'Too many password reset requests'
    },
    '/api/ai/generate': {
      windowMs: 60 * 1000,
      max: 10,                     // Expensive AI endpoints
      message: 'AI rate limit exceeded'
    }
  }
};

// Implementation with Redis for distributed systems
const createRateLimiter = (config) => {
  return rateLimit({
    store: new RedisStore({
      client: redisClient,
      prefix: 'rl:'
    }),
    ...config,
    handler: (req, res) => {
      // Log rate limit hits - might indicate attack
      securityLog.warn('Rate limit exceeded', {
        ip: req.ip,
        userId: req.user?.id,
        path: req.path
      });

      res.status(429).json({
        error: config.message || 'Too many requests',
        retryAfter: Math.ceil(config.windowMs / 1000)
      });
    }
  });
};

GraphQL Security: Unique Challenges

GraphQL's flexibility creates unique security challenges. Clients can request exactly the data they need—but attackers can also craft queries that bring down your server.

Query Complexity and Depth Attacks

GraphQL allows nested queries that can explode exponentially. A query requesting "users with their friends with their friends with their friends..." can create millions of database queries from a single API request.

// GraphQL security configuration
const server = new ApolloServer({
  typeDefs,
  resolvers,

  // CRITICAL: Disable introspection in production
  // Introspection reveals your entire schema to attackers
  introspection: process.env.NODE_ENV !== 'production',

  // Query complexity analysis
  // Each field has a cost; complex queries are rejected
  validationRules: [
    createComplexityLimitRule(1000, {
      // Define complexity for each field type
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,  // Lists multiply the cost

      onCost: (cost) => {
        if (cost > 500) {
          console.warn(`High-cost query: ${cost}`);
        }
      },

      formatErrorMessage: (cost) =>
        `Query cost ${cost} exceeds maximum allowed cost of 1000`
    }),

    // Query depth limiting
    // Prevents deeply nested queries like user.friends.friends.friends...
    depthLimit(7, { ignore: ['__schema', '__type'] })
  ],

  // Field-level authorization in context
  context: async ({ req }) => {
    const user = await authenticateRequest(req);
    return {
      user,
      // Pass authorizer to resolvers
      canAccessField: (fieldName) => {
        const sensitiveFields = ['ssn', 'salary', 'internalNotes'];
        if (sensitiveFields.includes(fieldName)) {
          return user?.roles?.includes('admin');
        }
        return true;
      }
    };
  },

  // Never expose internal errors
  formatError: (error) => {
    // Log the full error for debugging
    console.error('GraphQL error:', error);

    // Return sanitized error to client
    if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      return {
        message: 'An error occurred',
        extensions: { code: 'INTERNAL_SERVER_ERROR' }
      };
    }
    return error;
  }
});

API Gateway: Defense in Depth

An API gateway sits in front of your services and enforces security policies consistently. Even if a developer forgets to add rate limiting to their service, the gateway catches it.

# Kong API Gateway security configuration
# This configuration provides defense in depth for all services

services:
  - name: orders-api
    url: http://orders-service:8080
    routes:
      - name: orders-route
        paths: ["/api/orders"]
        strip_path: false

    plugins:
      # 1. Authentication: Verify JWT before request reaches service
      - name: jwt
        config:
          claims_to_verify: [exp, iss, aud]
          key_claim_name: kid
          secret_is_base64: false
          run_on_preflight: true

      # 2. Rate limiting: Protect against abuse
      - name: rate-limiting
        config:
          minute: 60
          hour: 1000
          policy: redis
          fault_tolerant: true
          hide_client_headers: false  # Show X-RateLimit-* headers

      # 3. Request size limiting: Prevent DoS via large payloads
      - name: request-size-limiting
        config:
          allowed_payload_size: 1      # 1 MB max
          size_unit: megabytes

      # 4. Bot detection: Block known bad actors
      - name: bot-detection
        config:
          deny: [googlebot, bingbot, baiduspider]  # Configure as needed

      # 5. Request validation: Enforce OpenAPI schema
      - name: request-validator
        config:
          body_schema: |
            { "$ref": "#/components/schemas/OrderRequest" }

      # 6. Security headers: Defense against common web attacks
      - name: response-transformer
        config:
          add:
            headers:
              - "X-Content-Type-Options: nosniff"
              - "X-Frame-Options: DENY"
              - "X-XSS-Protection: 1; mode=block"
              - "Content-Security-Policy: default-src 'self'"
              - "Strict-Transport-Security: max-age=31536000; includeSubDomains"

      # 7. Logging: Audit trail for security analysis
      - name: http-log
        config:
          http_endpoint: http://siem.internal:8080/api/logs
          content_type: application/json
          flush_timeout: 2

Monitoring and Incident Detection

Security logging isn't just for compliance—it's how you detect attacks in progress and investigate breaches. But logging everything creates noise; the key is logging the right things.

// Security-focused API logging middleware
const securityLogger = (req, res, next) => {
  const startTime = process.hrtime.bigint();
  const requestId = crypto.randomUUID();

  // Attach request ID for correlation
  req.id = requestId;
  res.setHeader('X-Request-ID', requestId);

  // Capture response
  res.on('finish', async () => {
    const durationMs = Number(process.hrtime.bigint() - startTime) / 1e6;

    const logEntry = {
      // Timing and identification
      timestamp: new Date().toISOString(),
      requestId,
      durationMs,

      // Request details
      method: req.method,
      path: req.path,
      query: sanitizeQueryParams(req.query),
      statusCode: res.statusCode,

      // Identity (for correlating actions)
      userId: req.user?.id,
      sessionId: req.session?.id,
      clientId: req.apiClient?.id,

      // Network context (for geo-blocking and forensics)
      ip: req.ip,
      userAgent: req.headers['user-agent'],
      origin: req.headers.origin,

      // Security-specific fields
      authMethod: req.authMethod,           // JWT, API key, etc.
      authSuccess: res.statusCode !== 401,
      authzSuccess: res.statusCode !== 403,

      // Request characteristics (for anomaly detection)
      bodySize: parseInt(req.headers['content-length'] || 0),
      responseSize: res.getHeader('content-length')
    };

    // Send to SIEM/logging infrastructure
    await securityLog.info('api_request', logEntry);

    // Real-time alerting for suspicious patterns
    await detectAnomalies(logEntry);
  });

  next();
};

// Anomaly detection patterns
async function detectAnomalies(log) {
  // Pattern 1: Brute force detection
  if (log.statusCode === 401) {
    const key = `failed_auth:${log.ip}`;
    const count = await redis.incr(key);
    await redis.expire(key, 300);  // 5 minute window

    if (count > 10) {
      await alert('Possible brute force attack', {
        ip: log.ip,
        failedAttempts: count,
        path: log.path
      });
    }
  }

  // Pattern 2: BOLA attempt detection
  if (log.statusCode === 404 && log.userId) {
    const key = `not_found:${log.userId}`;
    const count = await redis.incr(key);
    await redis.expire(key, 60);

    if (count > 20) {
      await alert('Possible BOLA enumeration attempt', {
        userId: log.userId,
        notFoundCount: count
      });
    }
  }

  // Pattern 3: Unusual geographic access
  const geo = await geolocate(log.ip);
  if (log.userId && geo.country) {
    const userCountries = await getUserRecentCountries(log.userId);
    if (!userCountries.includes(geo.country)) {
      await alert('Access from new country', {
        userId: log.userId,
        newCountry: geo.country,
        usualCountries: userCountries
      });
    }
  }
}

Building a Security-First API Culture

Tools and configurations matter, but culture matters more. The most secure organizations don't just have secure APIs—they have teams that think about security by default.

Security Checklist for Every API Endpoint

Before deploying any new endpoint, verify:

  • Authentication: Is the endpoint protected? Should it be?
  • Authorization: Does every resource access check permissions?
  • Input validation: Are all parameters validated? No SQL injection? No XSS?
  • Rate limiting: Are appropriate limits in place?
  • Error handling: Do errors reveal internal details?
  • Logging: Are security-relevant events captured?
  • Documentation: Is the endpoint documented? Is it supposed to be public?

Regular Security Testing

Automated security scanning should run in CI/CD, but it's not enough. Regular penetration testing by skilled humans finds vulnerabilities that scanners miss—especially business logic flaws like "buy an item, cancel the order, keep the item" or "apply discount code twice."

API Inventory Management

Shadow APIs—endpoints that exist but aren't documented or monitored—are common targets. Maintain an inventory of all APIs, including internal ones. Deprecate old versions aggressively. Every endpoint you remove is one less attack surface.

API security isn't a destination—it's a continuous practice of authentication, authorization, validation, monitoring, and improvement. The threat landscape evolves; your defenses must evolve with it.