Api Security Patterns

Production engineering guide for api security patterns covering patterns, implementation strategies, and operational best practices.

APIs are the connective tissue of modern systems. When building, integrating, or securing services that communicate over HTTP, you need patterns that ensure reliability, performance, and trust across boundaries. This page details proven, executable patterns for securing APIs—practical, repeatable strategies that address common failure modes and evolving threats. These patterns are not abstract advice; they are actionable blueprints that work across platforms, frameworks, and infrastructure, grounded in real-world HTTP, authentication, and data flows.

Authentication and Authorization

Token-Based Authentication with JWTs

Use JSON Web Tokens (JWTs) for stateless authentication in APIs. A client acquires a token from an identity provider or OAuth 2.0 authorization server and includes it in the Authorization header for each request.

GET /api/users/123 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The JWT consists of a header, payload, and signature. The signature is verified using a shared secret or public key. The payload must contain at least sub (subject), iat (issued at), and exp (expiration).

A service validates the token by:

  1. Parsing the JWT.
  2. Verifying the signature with the expected key.
  3. Checking exp and nbf (not before) claims.
  4. Optionally validating iss (issuer), aud (audience), and azp (authorized party).

If the token is invalid, the API returns:

{
  "error": "invalid_token",
  "error_description": "The access token is expired or invalid."
}

Common failure modes:

Use Authorization: Bearer <token> rather than Bearer <token> (with space) or Token <token>. The former is the de facto standard, and the latter leads to subtle, hard-to-debug issues.

Role-Based Access Control (RBAC) with Scopes

Define fine-grained access using scopes. Scopes are strings that describe a client’s intended permissions.

{
  "sub": "1234567890",
  "name": "John Doe",
  "scope": "read:users write:posts",
  "exp": 1640995200,
  "iss": "https://auth.example.com"
}

The API checks for required scopes in the Authorization header using the scope claim. For a request to /api/posts, the service requires read:posts or write:posts.

When a client makes a request:

GET /api/posts HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>

The server:

  1. Extracts scope from the JWT.
  2. Splits it into an array: ["read:users", "write:posts"].
  3. Checks that the required scopes are present.

If write:posts is missing, return:

{
  "error": "insufficient_scope",
  "error_description": "The request requires the 'write:posts' scope.",
  "scope": "write:posts"
}

A common failure: clients omit the scope claim entirely, or use scopes (plural) instead of scope. The API expects scope but receives scopes, leading to missing scopes and inconsistent permissions.

Dynamic Authorization Decisions with OPA

For complex, context-aware authorization, integrate Open Policy Agent (OPA) as a decision service. The API sends a request with a structured context to OPA.

{
  "input": {
    "method": "POST",
    "path": "/api/posts",
    "user": {
      "id": "12345",
      "roles": ["editor", "admin"],
      "department": "marketing"
    },
    "resource": {
      "id": "789",
      "type": "post",
      "author_id": "12345",
      "published": false
    }
  }
}

OPA evaluates a policy written in Rego:

default allow = false

allow {
  input.method = "POST"
  input.resource.type = "post"
  input.user.roles[_] = "editor"
}

allow {
  input.method = "POST"
  input.resource.type = "post"
  input.user.department = "marketing"
  input.user.roles[_] = "admin"
}

OPA returns:

{
  "result": true
}

Or:

{
  "result": false,
  "explanation": "User is not a marketing admin."
}

The API uses the result to decide whether to proceed. If result is false, it returns 403 Forbidden with the explanation.

Failure modes:

Data Protection

Request and Response Payload Validation

Validate all incoming JSON payloads using JSON Schema. Define a schema for the POST /api/users endpoint:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["name", "email"],
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "email": { "type": "string", "format": "email" },
    "phone": { "type": "string", "pattern": "^\\+?[1-9]\\d{9,14}$" },
    "preferences": {
      "type": "object",
      "properties": {
        "theme": { "enum": ["light", "dark", "auto"] },
        "notifications": { "type": "boolean" }
      },
      "additionalProperties": false
    }
  },
  "additionalProperties": false
}

The API validates each request body:

POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "name": "Alice",
  "email": "[email protected]",
  "phone": "+1234567890",
  "preferences": {
    "theme": "dark",
    "notifications": true
  }
}

If validation fails, return:

{
  "error": "validation_failed",
  "details": [
    {
      "field": "email",
      "message": "Email is not a valid email address.",
      "value": "aliceexample.com"
    },
    {
      "field": "preferences.theme",
      "message": "Value must be one of: light, dark, auto.",
      "value": "lightblue"
    }
  ]
}

Common pitfalls:

Data Encryption in Transit and at Rest

Secure data with TLS 1.3 for transport and AES-256 encryption for data at rest.

Configure the API server to:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

For data at rest, encrypt sensitive fields in the database:

ALTER TABLE users ADD COLUMN encrypted_email BYTEA;
UPDATE users SET encrypted_email = pgp_sym_encrypt(email, 'my-secret-key');

The application decrypts on read:

SELECT pgp_sym_decrypt(encrypted_email, 'my-secret-key') AS email FROM users WHERE id = 123;

Failure modes:

Error Handling and Monitoring

Standardized Error Responses

Define a consistent error format across all endpoints. Use the following structure:

{
  "error": "resource_not_found",
  "message": "The requested resource could not be found.",
  "details": {
    "resource": "user",
    "id": 456,
    "timestamp": "2023-01-15T12:34:56Z"
  },
  "trace_id": "a1b2c3d4e5f6"
}

The trace_id is generated at the API gateway and passed through the entire call stack.

Example error response:

HTTP/1.1 404 Not Found
Content-Type: application/json
X-Trace-ID: a1b2c3d4e5f6

{
  "error": "resource_not_found",
  "message": "The requested user could not be found.",
  "details": {
    "resource": "user",
    "id": 456,
    "timestamp": "2023-01-15T12:34:56Z"
  },
  "trace_id": "a1b2c3d4e5f6"
}

Failure modes:

Request-Response Logging with Structured Logs

Log each API request and response using structured formats (e.g., JSON, Syslog). Include:

Example log entry:

{
  "timestamp": "2023-01-15T12:34:56.789Z",
  "level": "info",
  "service": "api-gateway",
  "trace_id": "a1b2c3d4e5f6",
  "request": {
    "method": "GET",
    "path": "/api/users/123",
    "headers": {
      "Authorization": "Bearer <token>",
      "User-Agent": "curl/7.81.0"
    },
    "body": null
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "body": {
      "id": 123,
      "name": "John Doe"
    }
  },
  "metrics": {
    "request_duration_ms": 124,
    "processing_duration_ms": 87
  }
}

Failure modes:

Rate Limiting and Throttling

Token Bucket Algorithm for Rate Limits

Implement rate limiting using the token bucket algorithm. A client receives a fixed number of tokens per time interval.

For example: 1000 requests per minute (1000 tokens, refill rate 16.67 tokens per second).

The API checks for rate limits by:

  1. Maintaining a bucket for each client (e.g., by API key, user ID, or IP).
  2. On each request:
{
  "error": "rate_limit_exceeded",
  "message": "Rate limit exceeded. Try again in 1.5 seconds.",
  "retry_after": 1500,
  "limit": 1000,
  "remaining": 998,
  "reset": 1673823456
}

The retry_after is in milliseconds. The client can use it to delay the next request.

Common failure modes:

Shared Rate Limiting with Redis

Use Redis to store and update token buckets across multiple API instances.


HSET rate_limit:client:123 tokens 1000 last_refill 1673823000

# On request, update and check:
INCRBY rate_limit:client:123:requests 1
EXPIRE rate_limit:client:123 60

The API uses HGETALL to retrieve the bucket and HSET to update it.

Failure modes:

Security Headers and Metadata

Security Headers for HTTP Responses

Set security headers at the API gateway or edge:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin

The Content-Security-Policy prevents XSS by allowing only scripts from 'self' and inline scripts.

Failure modes:

Request Metadata and Context Propagation

Pass metadata through the API stack using HTTP headers.

X-Request-ID: a1b2c3d4e5f6
X-Client-IP: 192.168.1.100
X-User-ID: 12345
X-Trace-ID: a1b2c3d4e5f6
X-Source: mobile-app

The API logs the X-Request-ID and X-Trace-ID to correlate logs across services.

Failure modes:

Idempotency and Retry

Idempotent Requests with Idempotency Keys

For state-changing operations, use idempotency keys to ensure that repeated requests have the same effect.

The client includes an Idempotency-Key header:

POST /api/orders HTTP/1.1
Host: api.example.com
Idempotency-Key: 1234567890abcdef
Content-Type: application/json

{
  "customer_id": 123,
  "items": [ { "product_id": 456, "quantity": 2 } ]
}

The server checks if an order with Idempotency-Key: 1234567890abcdef already exists.

If yes:

If no:

Failure modes:

This page defines the core mechanics of securing APIs—what to do, how to do it, and what can go wrong—without relying on any specific product. Every pattern is executable, observable, and failure-aware.

This page was rewritten on 16 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.