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.
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:
exp and nbf (not before) claims.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:
exp claim is missing: token is accepted but never expires.iat not checked: tokens are issued in the future, leading to early invalidity.aud is wrong: client sends token for api.example.com to api.staging.example.com.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.
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:
scope from the JWT.["read:users", "write:posts"].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.
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:
user.id vs user.userId, leading to null values.503 Service Unavailable.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:
additionalProperties: false not set: client sends extra fields, but server accepts them silently.format: "email" not validated: [email protected] is accepted, but [email protected] is not.preferences.theme as light, but the API expects theme in preferences.theme, leading to missing preferences.Secure data with TLS 1.3 for transport and AES-256 encryption for data at rest.
Configure the API server to:
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA512 cipher suite.Strict-Transport-Security header: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:
pgp_sym_encrypt returns NULL when email is NULL: missing email field leads to NULL in encrypted_email.max-age not set: HSTS is enabled but not enforced.includeSubDomains missing: subdomains do not get HSTS, leading to mixed content.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:
trace_id missing: clients cannot correlate logs across services.details object is empty or omitted: callers cannot debug errors.message is not descriptive: Resource not found instead of The requested user could not be found.Log each API request and response using structured formats (e.g., JSON, Syslog). Include:
request_duration_ms, processing_duration_ms.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:
body is null but request.body is a string: body is not parsed.metrics is missing: performance monitoring relies on unstructured logs.trace_id not propagated: logs across services are disjointed.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:
429 Too Many Requests.{
"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:
reset not set: client cannot predict when the bucket resets.remaining not updated: client sees remaining: 1000, but after one request, it is still 1000.retry_after in seconds, but API expects milliseconds: client waits 1 second instead of 1500 milliseconds.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:
200 even when bucket is empty.EXPIRE not set: bucket remains in memory indefinitely, but Redis eviction leads to lost state.INCRBY not atomic: concurrent requests overwrite each other, leading to missed requests.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:
X-Content-Type-Options: nosniff not set: browser guesses MIME type, leading to unexpected rendering of application/json as HTML.X-Frame-Options: DENY not set: API is embeddable in iframes but vulnerable to clickjacking.Referrer-Policy not set: sensitive data in Authorization header is leaked to third parties.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:
X-Request-ID not set by client: logs have null or empty ID.X-Trace-ID not propagated: microservices fail to link logs.X-Source not used: no way to distinguish traffic from web, mobile, or CLI clients.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:
ETag and Last-Modified headers.200 OK with X-Idempotency-Result: already_processed.If no:
Idempotency-Key in a database.201 Created with Location header.Failure modes:
Idempotency-Key not set: client sends multiple identical requests, but server creates multiple orders.ETag not set: client cannot detect changes.X-Idempotency-Result missing: client cannot distinguish between new and existing resources.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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy