---
title: "Secure API Design: Authentication, Authorization, Input Validation, and OWASP API Top 10"
description: "Building secure APIs from the ground up — authentication schemes (OAuth2, API keys, JWTs), authorization patterns, input validation, rate limiting, and defenses against the OWASP API Security Top 10 risks."
url: https://agent-zone.ai/knowledge/security/secure-api-design/
section: knowledge
date: 2026-02-22
categories: ["security"]
tags: ["api-security","oauth2","jwt","authentication","authorization","owasp","rate-limiting","input-validation"]
skills: ["api-authentication","oauth2-implementation","input-validation","rate-limit-design"]
tools: ["oauth2","jwt","openapi","envoy","nginx"]
levels: ["intermediate"]
word_count: 1877
formats:
  json: https://agent-zone.ai/knowledge/security/secure-api-design/index.json
  html: https://agent-zone.ai/knowledge/security/secure-api-design/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Secure+API+Design%3A+Authentication%2C+Authorization%2C+Input+Validation%2C+and+OWASP+API+Top+10
---


# Secure API Design

Every API exposed to any network — public or internal — is an attack surface. The difference between a secure API and a vulnerable one is not exotic cryptography. It is consistent application of known patterns: authenticate every request, authorize every action, validate every input, and limit every resource.

## Authentication Schemes

### API Keys

The simplest scheme. The client sends a static key in a header:

```
GET /api/v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_abc123def456
```

API keys are appropriate for:
- Server-to-server communication where both sides are trusted.
- Rate limiting and usage tracking per client.
- Low-sensitivity endpoints where the key is the only required credential.

API keys are not appropriate for:
- User-facing authentication (no concept of user identity or sessions).
- Sensitive operations (keys are long-lived, hard to scope, and easy to leak).

**Implementation rules:**
- Always transmit keys over TLS.
- Store hashed keys server-side (SHA-256 minimum). Never store plaintext keys.
- Support key rotation — allow multiple active keys per client with expiry dates.
- Prefix keys with a type indicator (`sk_live_`, `sk_test_`) so leaked keys can be identified and revoked.

### JWT (JSON Web Tokens)

JWTs are signed tokens containing claims. The server verifies the signature without a database lookup:

```
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsInNjb3BlIjoicmVhZCB3cml0ZSIsImlhdCI6MTcwOTI0ODAwMCwiZXhwIjoxNzA5MjUxNjAwfQ.signature
```

A JWT contains three parts (header.payload.signature):

```json
// Header
{"alg": "RS256", "typ": "JWT", "kid": "key-2026-01"}

// Payload
{
  "sub": "user-123",
  "scope": "read write",
  "iat": 1709248000,
  "exp": 1709251600,
  "iss": "https://auth.example.com"
}
```

**JWT security rules:**

- **Always use asymmetric signing (RS256 or ES256).** Symmetric signing (HS256) means the same secret verifies and creates tokens — if any service can verify, it can also forge tokens.
- **Always validate `exp`, `iss`, and `aud` claims.** An expired token from the wrong issuer for the wrong audience should be rejected.
- **Short expiry times.** Access tokens should expire in 15-60 minutes. Use refresh tokens for longer sessions.
- **Never put sensitive data in the payload.** JWTs are signed, not encrypted. Anyone can decode the payload.
- **Use `kid` (key ID) in the header.** This allows key rotation without breaking existing tokens.

```go
// Validation example (Go)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
    // Verify algorithm to prevent algorithm confusion attacks
    if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
        return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    }
    kid := token.Header["kid"].(string)
    return getPublicKey(kid)
})

if err != nil || !token.Valid {
    return ErrUnauthorized
}

claims := token.Claims.(jwt.MapClaims)
if !claims.VerifyIssuer("https://auth.example.com", true) {
    return ErrUnauthorized
}
if !claims.VerifyAudience("https://api.example.com", true) {
    return ErrUnauthorized
}
```

### OAuth2 and OIDC

OAuth2 is a framework for delegated authorization. OpenID Connect (OIDC) adds an identity layer on top. Together, they handle the common case of "user logs in via identity provider, gets a token, uses token to call API."

The most important flows:

**Authorization Code Flow (with PKCE)** — For browser-based and mobile apps:

```
User → App → Redirect to Identity Provider (login page)
    → User authenticates
    → IDP redirects back to app with authorization code
    → App exchanges code for access token (server-side)
    → App uses access token to call API
```

PKCE (Proof Key for Code Exchange) prevents authorization code interception. Always use PKCE, even for server-side apps.

**Client Credentials Flow** — For service-to-service:

```
Service → POST /token with client_id + client_secret
    → IDP returns access token
    → Service uses access token to call API
```

No user involved. The client authenticates directly with its credentials.

**Implementation rules:**
- Never implement your own OAuth2 server unless you deeply understand the spec. Use Keycloak, Auth0, Okta, or cloud-native equivalents.
- Always validate the `aud` claim to prevent token confusion attacks (token issued for Service A used against Service B).
- Store refresh tokens securely (encrypted, server-side). Rotate refresh tokens on every use.
- Implement token revocation for logout and compromised tokens.

## Authorization Patterns

Authentication answers "who are you?" Authorization answers "what can you do?"

### Role-Based Access Control (RBAC)

Users are assigned roles. Roles have permissions. Check the role on every request:

```python
PERMISSIONS = {
    "admin": ["read", "write", "delete", "manage_users"],
    "editor": ["read", "write"],
    "viewer": ["read"],
}

def authorize(user_role: str, required_permission: str) -> bool:
    return required_permission in PERMISSIONS.get(user_role, [])
```

RBAC is simple and works well when permissions map cleanly to roles. It breaks down when you need fine-grained access (user A can edit their own posts but not user B's).

### Attribute-Based Access Control (ABAC)

Decisions based on attributes of the user, resource, and context:

```python
def can_edit_document(user, document):
    if user.role == "admin":
        return True
    if document.owner_id == user.id:
        return True
    if user.department == document.department and "editor" in user.roles:
        return True
    return False
```

More flexible than RBAC but harder to audit and reason about. Use when RBAC is too coarse.

### Broken Object-Level Authorization (BOLA)

The number one API vulnerability (OWASP API #1). A user requests a resource by ID and gets it, even if it belongs to someone else:

```
GET /api/v1/users/456/documents/789
# User 123 can access user 456's documents by changing the ID
```

Fix: always verify the authenticated user has access to the specific resource:

```python
@app.get("/api/v1/documents/{doc_id}")
def get_document(doc_id: str, current_user: User = Depends(get_current_user)):
    doc = db.get_document(doc_id)
    if doc is None:
        raise HTTPException(404)
    if doc.owner_id != current_user.id and not current_user.is_admin:
        raise HTTPException(403)
    return doc
```

Never rely on the client sending the correct user ID. Always derive the user identity from the authenticated token.

## Input Validation

Every input is hostile until validated. This applies to path parameters, query parameters, headers, and request bodies.

### Validation Strategy

```
Request arrives
  → Schema validation (structure, types, required fields)
  → Business rule validation (ranges, formats, relationships)
  → Sanitization (encoding, escaping for output context)
  → Process request
```

### Schema Validation with OpenAPI

Define your API schema and validate against it:

```yaml
paths:
  /api/v1/users:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, name]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  pattern: "^[a-zA-Z0-9 .'-]+$"
                age:
                  type: integer
                  minimum: 0
                  maximum: 150
              additionalProperties: false
```

`additionalProperties: false` rejects any fields not in the schema. Without it, clients can send arbitrary fields that might be processed by downstream code.

### Injection Prevention

- **SQL injection:** Use parameterized queries. Never concatenate user input into SQL strings.

```python
# WRONG
cursor.execute(f"SELECT * FROM users WHERE id = '{user_id}'")

# RIGHT
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
```

- **Command injection:** Never pass user input to shell commands. Use libraries that take arguments as arrays.

```python
# WRONG
os.system(f"convert {filename} output.png")

# RIGHT
subprocess.run(["convert", filename, "output.png"], check=True)
```

- **NoSQL injection:** MongoDB and similar databases are vulnerable to operator injection:

```python
# WRONG: user_input could be {"$gt": ""} which matches everything
db.users.find({"username": user_input})

# RIGHT: explicitly cast to string
db.users.find({"username": str(user_input)})
```

### Request Size Limits

Set maximum request body sizes to prevent memory exhaustion:

```python
# FastAPI
app = FastAPI()
app.add_middleware(
    TrustedHostMiddleware, allowed_hosts=["api.example.com"]
)

# Nginx
client_max_body_size 1m;

# Envoy
http_connection_manager:
  max_request_headers_kb: 60
```

## Rate Limiting

Rate limiting prevents abuse, protects backend resources, and ensures fair usage.

### Token Bucket Algorithm

The most common approach. Each client has a bucket that fills with tokens at a fixed rate. Each request consumes a token. When the bucket is empty, requests are rejected.

```
Bucket capacity: 100 tokens
Refill rate: 10 tokens/second
Request arrives:
  - If bucket has tokens: allow, remove 1 token
  - If bucket is empty: reject with 429 Too Many Requests
```

### Rate Limit Headers

Return rate limit information so clients can self-regulate:

```
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1709248060
Retry-After: 30  (only on 429 responses)
```

### Tiered Rate Limits

Different limits for different contexts:

```yaml
rate_limits:
  # Global: protect the infrastructure
  - key: global
    rate: 10000/minute

  # Per-IP: prevent single-source abuse
  - key: ip
    rate: 100/minute

  # Per-API-key: enforce plan limits
  - key: api_key
    rate:
      free: 60/minute
      pro: 600/minute
      enterprise: 6000/minute

  # Per-endpoint: protect expensive operations
  - key: endpoint
    endpoints:
      /api/v1/search: 20/minute
      /api/v1/export: 5/minute
```

### Where to Rate Limit

Rate limit at the edge (API gateway, load balancer), not in the application. By the time a request reaches your application code, it has already consumed resources. Nginx, Envoy, Cloudflare, and AWS API Gateway all support rate limiting natively.

## OWASP API Security Top 10

The OWASP API Security Top 10 catalogs the most critical API vulnerabilities. Here is how each applies and how to defend against it:

**API1 — Broken Object-Level Authorization (BOLA).** Covered above. Always verify the authenticated user has access to the specific resource they are requesting. Do not trust client-supplied IDs.

**API2 — Broken Authentication.** Weak authentication mechanisms: no rate limiting on login, credentials in URLs, weak password policies, missing token validation. Fix: use proven auth libraries, rate limit auth endpoints aggressively, never accept tokens without full validation.

**API3 — Broken Object Property-Level Authorization.** The API returns more data than the user should see, or accepts writes to fields the user should not modify. Fix: explicitly define response schemas per role. Never return the raw database object.

```python
# WRONG: returns all fields including is_admin
return user.dict()

# RIGHT: return only allowed fields
return {"id": user.id, "name": user.name, "email": user.email}
```

**API4 — Unrestricted Resource Consumption.** No limits on request size, pagination, or computation. An attacker requests 10 million records or triggers an expensive operation repeatedly. Fix: enforce pagination limits, request size limits, query complexity limits, and rate limiting.

**API5 — Broken Function-Level Authorization.** Admin endpoints accessible to regular users. Fix: enforce authorization checks on every endpoint, not just data access. Test that non-admin users get 403 on admin routes.

**API6 — Unrestricted Access to Sensitive Business Flows.** Automated abuse of legitimate functionality: mass account creation, inventory hoarding, scraping. Fix: rate limiting, CAPTCHA for sensitive flows, behavioral detection.

**API7 — Server-Side Request Forgery (SSRF).** The API fetches a URL provided by the user and the attacker points it at internal services. Fix: validate and allowlist URLs, block internal IP ranges, use a dedicated egress proxy.

```python
import ipaddress

def is_safe_url(url: str) -> bool:
    parsed = urlparse(url)
    try:
        ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
    except (socket.gaierror, ValueError):
        return False
    return ip.is_global  # Reject private, loopback, link-local
```

**API8 — Security Misconfiguration.** Default credentials, verbose error messages, unnecessary HTTP methods enabled, missing security headers. Fix: harden defaults, strip stack traces from production errors, disable unused HTTP methods, add security headers.

**API9 — Improper Inventory Management.** Forgotten API versions, undocumented endpoints, test environments exposed to the internet. Fix: maintain an API inventory, decommission old versions, scan for exposed endpoints.

**API10 — Unsafe Consumption of APIs.** Your API trusts data from third-party APIs without validation. The third party is compromised or returns malicious data. Fix: validate all external API responses the same way you validate user input.

## Security Headers

Every API response should include:

```
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
```

For APIs that never serve HTML, `Content-Security-Policy: default-src 'none'` prevents any injected content from executing.

## Common Mistakes

1. **Trusting client-supplied user IDs instead of deriving identity from the token.** The token says who the user is. The path parameter says which resource they want. Never trust the path to determine identity.
2. **Returning raw database objects in API responses.** Internal fields, password hashes, admin flags, and foreign keys leak to clients. Always map to explicit response schemas.
3. **Rate limiting only by IP.** Shared IPs (corporate NAT, VPNs, cloud functions) penalize legitimate users. Rate limit by API key or authenticated identity as the primary key, with IP as a fallback for unauthenticated endpoints.
4. **Logging sensitive data.** Request logs that include authorization headers, API keys, or request bodies with passwords create a second exposure surface. Redact sensitive fields before logging.
5. **Relying on API gateway auth without defense in depth.** If the gateway is misconfigured or bypassed, the application is unprotected. Validate auth at both the gateway and the application layer.

