Skip to content

Web Services & APIs — Security

Comprehensive security reference for web APIs: OWASP API Security Top 10, authentication/authorization threat models, protocol-specific attack surfaces, transport security, and defensive patterns.


OWASP API Security Top 10 (2023)

The OWASP API Security Top 10 is the authoritative classification of the most critical API vulnerabilities. The 2023 edition reflects modern API threat landscape.

API1:2023 — Broken Object Level Authorization (BOLA)

The most prevalent API vulnerability. The attacker manipulates resource IDs in the request to access objects belonging to other users.

# Attacker changes orderId to access another user's order
GET /api/v2/orders/order_OTHER_USER_123
Authorization: Bearer attacker_token

# Server returns the order without verifying ownership → BOLA

Root cause: Authorization checks performed at the endpoint level but NOT at the object level. The code retrieves the object by ID without verifying it belongs to the authenticated user.

# VULNERABLE — fetches any order by ID
@app.get("/orders/{order_id}")
async def get_order(order_id: str, db: DB):
    return db.orders.find_by_id(order_id)  # no ownership check

# SECURE — scopes query to authenticated user
@app.get("/orders/{order_id}")
async def get_order(order_id: str, user: User = Depends(get_current_user), db: DB):
    order = db.orders.find_one({"_id": order_id, "userId": user.id})
    if not order:
        raise HTTPException(404)
    return order

Mitigations: - Enforce object-level authorization in every data access function - Use random, non-sequential IDs (UUIDs/ULIDs) — does NOT replace authorization but reduces enumeration - Write integration tests that specifically verify cross-user access is denied

API2:2023 — Broken Authentication

Weak or missing authentication mechanisms allow attackers to impersonate legitimate users.

Common weaknesses: - No rate limiting on login/token endpoints → brute force - Credentials in query strings (?api_key=secret) → logged by proxies, browsers, CDN - No token expiration or excessively long TTL - JWT alg: none accepted → forged tokens - Password reset tokens that don't expire or aren't single-use

Mitigations: - Rate limit authentication endpoints aggressively (e.g., 5 failures per minute per IP) - Use Authorization header only — never query params for secrets - Short-lived access tokens (15 min) + refresh tokens (httpOnly, secure cookies) - Explicitly validate JWT algorithm on the server — never trust the alg header

API3:2023 — Broken Object Property Level Authorization

Combines the former "Excessive Data Exposure" and "Mass Assignment." The API exposes object properties the user shouldn't see or allows them to modify properties they shouldn't control.

// API response includes internal fields the client shouldn't see
{
  "id": "user_123",
  "name": "Alice",
  "email": "[email protected]",
  "role": "user",
  "passwordHash": "$2b$12$...",        // excessive data exposure
  "internalCreditScore": 780,          // excessive data exposure
  "isAdmin": false                     // modifiable via mass assignment
}
# Mass assignment — attacker sends field they shouldn't control
PATCH /api/v2/users/me
{"name": "Alice", "role": "admin", "isAdmin": true}

Mitigations: - Explicit response schemas — allowlist fields per role, never return raw DB objects - Input DTOs with strict field allowlists — reject unknown fields - In Django REST Framework: use fields = (...) never fields = '__all__' - Separate read/write schemas (GraphQL input types already enforce this)

API4:2023 — Unrestricted Resource Consumption

The API does not limit the size or number of resources a client can request, enabling denial-of-service.

Attack vectors: - No pagination limits → GET /users?limit=999999999 - Unbounded file uploads → 10 GB payload - Expensive operations without rate limiting → repeated POST /reports - Batch operations without bounds → POST /batch with 100K items - GraphQL query depth/complexity bombs

Mitigations: - Enforce max_page_size (e.g., 100 items) - Set maximum request body size (nginx: client_max_body_size 10m) - Rate limit per user, per endpoint, and per expensive operation - GraphQL: depth limiting + complexity scoring + persisted queries - Set server-side timeouts for all operations

API5:2023 — Broken Function Level Authorization (BFLA)

Regular users can invoke administrative or privileged functions by simply calling the endpoint directly.

# Regular user discovers admin endpoint
DELETE /api/v2/admin/users/user_456
Authorization: Bearer regular_user_token

# Server processes it without checking role → BFLA

Mitigations: - Deny by default — every endpoint requires explicit role mapping - Separate admin routes with dedicated middleware: /admin/... with admin-only middleware - Don't rely on client-side hiding of admin features - Automated testing: enumerate all endpoints and verify each returns 403 for non-admin users

API6:2023 — Unrestricted Access to Sensitive Business Flows

Attackers automate legitimate business flows at scale (ticket scalping, coupon abuse, mass account creation, inventory hoarding).

Mitigations: - CAPTCHA / proof-of-work for sensitive flows - Device fingerprinting for anomaly detection - Rate limiting by business context (e.g., max 3 coupons per user per day) - Bot detection (behavioral analysis, honeypot fields)

API7:2023 — Server-Side Request Forgery (SSRF)

The API accepts a URL from the user and fetches it server-side without validating the target.

// User-supplied webhook URL points to internal infrastructure
POST /api/v2/webhooks
{
  "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}
// Server fetches AWS IMDS credentials → full cloud account compromise

SSRF deny-list (must block):

Target IP/Domain
AWS IMDS 169.254.169.254, metadata.amazonaws.com
GCP Metadata metadata.google.internal, 169.254.169.254
Azure IMDS 169.254.169.254
Localhost 127.0.0.0/8, 0.0.0.0/8, ::1/128
Private networks (RFC 1918) 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Link-local 169.254.0.0/16

Mitigations: - Validate and sanitize all user-supplied URLs - Block requests to private/reserved IP ranges (deny-list above) - Use an allowlist of permitted domains when possible - Disable HTTP redirects in outbound requests, or re-validate after each redirect - Run outbound requests from an isolated network zone (no access to IMDS or internal services)

API8:2023 — Security Misconfiguration

Broad category: missing security headers, verbose error messages, unnecessary HTTP methods enabled, default credentials, CORS misconfiguration.

Checklist:

Configuration Secure Setting
TLS TLS 1.2+ only, disable SSLv3/TLS 1.0/1.1
CORS Explicit origin allowlist, never * with credentials
Error responses Generic messages; never expose stack traces, SQL errors, or internal paths
HTTP methods Disable unused methods (TRACE, TRACK)
Security headers X-Content-Type-Options: nosniff, Strict-Transport-Security, X-Frame-Options
Default credentials Remove all defaults; rotate all secrets on deployment
Debug endpoints Remove /debug, /metrics, /health from public-facing routes (or protect them)
API documentation Disable Swagger UI / GraphiQL in production unless intentionally public

API9:2023 — Improper Inventory Management

Organizations lose track of which API versions, endpoints, and environments are exposed. Shadow APIs, deprecated endpoints, and forgotten dev/staging environments become attack surfaces.

Mitigations: - Maintain a complete API inventory (every endpoint, version, environment) - Automate endpoint discovery from code (OpenAPI spec generation) - Sunset deprecated API versions with Deprecation and Sunset headers (RFC 8594) - Network segmentation: dev/staging APIs must not be reachable from the internet - Regular API surface audit: compare actual traffic to documented endpoints

API10:2023 — Unsafe Consumption of APIs

The API trusts data received from third-party APIs/services without validating it — the third party becomes an attack vector.

# VULNERABLE — trusts third-party response blindly
def enrich_user(user):
    third_party_data = requests.get(f"https://partner-api.com/users/{user.id}").json()
    user.name = third_party_data["name"]       # could contain XSS payload
    user.credit_limit = third_party_data["credit_limit"]  # could be manipulated
    user.save()

# SECURE — validate and sanitize
def enrich_user(user):
    resp = requests.get(
        f"https://partner-api.com/users/{user.id}",
        timeout=5
    )
    resp.raise_for_status()
    data = ThirdPartyUserSchema.model_validate(resp.json())  # Pydantic validation
    user.name = bleach.clean(data.name)
    user.save()

Mitigations: - Validate all third-party responses against a strict schema - Sanitize data before storing or rendering - Use timeouts and circuit breakers for all outbound calls - Apply the same security standards to consumed APIs as you apply to your own inputs


JWT Attack Vectors

Algorithm Confusion (None / HMAC → RSA)

Attack 1: alg:none
  Attacker changes JWT header to {"alg": "none"}
  Strips signature → server accepts unsigned token

Attack 2: RS256 → HS256
  Server uses RS256 (public/private key pair)
  Attacker sets alg to HS256 and signs with the PUBLIC key
  Vulnerable server uses the public key as HMAC secret → signature validates

Defense: Never read the algorithm from the JWT header. Hardcode the expected algorithm on the server:

// SECURE — explicitly specify expected algorithm
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build();
DecodedJWT decodedToken = verifier.verify(token);

Token Sidejacking

If the JWT is stored in localStorage, XSS can steal it. If stored in a regular cookie, CSRF can use it.

Defense — Fingerprint binding: 1. On login, generate a random fingerprint 2. Store fingerprint hash in the JWT claims 3. Store fingerprint plaintext in a __Secure-Fgp httpOnly, secure, sameSite cookie 4. On each request, hash the cookie fingerprint and compare to the claim

This binds the token to the browser session — even if the JWT is stolen via XSS, the attacker can't supply the httpOnly cookie.

JWK/JKU Injection

Attacker sets the jku (JWK Set URL) header to their own server hosting a crafted public key, then signs with the matching private key. Server fetches attacker's key → signature validates.

Defense: Never fetch keys from URLs in the JWT header. Use a static JWKS endpoint configured on the server.


Protocol-Specific Security

REST Security

Security headers every REST API should set:

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

Input validation rules: - Validate length, range, format, and type for all parameters - Use strong types (numbers, booleans, dates) — don't accept strings for everything - Constrain string inputs with regex - Reject request bodies exceeding size limits (HTTP 413) - Parse XML with XXE protections (disable external entities, DTD processing) - Log input validation failures — a spike indicates probing

GraphQL Security

GraphQL has a unique attack surface because of its flexibility:

Threat Attack Defense
Introspection abuse Attacker queries __schema to map the entire API Disable introspection in production
Depth bomb { user { friends { friends { friends { ... } } } } } Depth limiting (e.g., max 10 levels)
Width bomb Request all fields on hundreds of objects Complexity scoring per field
Batch attack Send array of mutations in one request Limit batch size
BOLA via node field { node(id: "OTHER_USER_ID") { ... on User { email } } } Remove node/nodes relay fields or enforce authorization
Field suggestion leak Typo returns "Did you mean X?" → reveals schema Disable field suggestions in production

graphql-shield authorization example:

import { rule, shield, and, or, not } from "graphql-shield";

const isAuthenticated = rule({ cache: "contextual" })(
  async (parent, args, ctx, info) => ctx.user !== null
);

const isAdmin = rule({ cache: "contextual" })(
  async (parent, args, ctx, info) => ctx.user.role === "admin"
);

const permissions = shield({
  Query: {
    users: and(isAuthenticated, isAdmin),
    me: isAuthenticated,
  },
  Mutation: {
    deleteUser: and(isAuthenticated, isAdmin),
    updateProfile: isAuthenticated,
  },
  User: {
    email: isAuthenticated,
    passwordHash: isAdmin,  // only admins can see this field
  },
});

Check for relay node exposure:

cat schema.json | jq '.data.__schema.types[] | select(.name=="Query") | .fields[] | .name' | grep node

gRPC Security

Transport security: - Always use TLS in production — grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)) - For internal service mesh: mTLS via Istio/SPIFFE - Never use grpc.WithInsecure() outside of local development

Authentication interceptors:

// API key validation from metadata
func validateAPIKey(ctx context.Context) error {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return status.Error(codes.Unauthenticated, "missing metadata")
    }
    keys := md["x-api-key"]
    if len(keys) == 0 || !isValidAPIKey(keys[0]) {
        return status.Error(codes.Unauthenticated, "invalid API key")
    }
    return nil
}

Protobuf input validation (protoc-gen-validate / buf validate):

syntax = "proto3";
import "validate/validate.proto";

message CreateUserRequest {
  string email = 1 [(validate.rules).string.email = true];
  string name  = 2 [(validate.rules).string = {min_len: 1, max_len: 100}];
  int32 age    = 3 [(validate.rules).int32 = {gte: 0, lte: 150}];
}

gRPC security testing:

# Test if endpoint requires auth
grpcurl -plaintext localhost:50051 myservice.MyService/GetUser

# Test with invalid token
grpcurl -plaintext \
  -H "authorization: Bearer invalid_token" \
  localhost:50051 myservice.MyService/GetUser

# Test with valid token
grpcurl -plaintext \
  -H "authorization: Bearer $(get_valid_token)" \
  -d '{"user_id": "42"}' \
  localhost:50051 myservice.MyService/GetUser

gRPC security assessment checklist: 1. All methods enforce authentication and authorization 2. Input validation applied to all message fields 3. Rate limiting and resource exhaustion protections active 4. TLS configuration and certificate handling verified 5. Error messages don't disclose sensitive information (use gRPC status codes, not stack traces)

WebSocket Security

Threat Attack Defense
No origin check Cross-site WebSocket hijacking (CSWSH) Validate Origin header during handshake
Missing auth Unauthenticated connections Authenticate during handshake (token in query/header) or in first message
Injection Malicious payloads in messages Validate and sanitize all incoming messages
Data exfiltration Sensitive data over unencrypted WS Always use wss:// (WebSocket over TLS)
Resource exhaustion Opening thousands of connections Per-IP connection limits, idle timeout
// Server-side: validate origin during upgrade
server.on('upgrade', (request, socket, head) => {
  const origin = request.headers.origin;
  if (!ALLOWED_ORIGINS.includes(origin)) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }
  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request);
  });
});

Webhook Security

Threat Defense
Forged payloads HMAC-SHA256 signature verification (see operations#payload-signing-hmac-sha256)
Replay attacks Include timestamp in signature; reject if |now - timestamp| > 5 min
DDoS via webhook floods Rate limit incoming webhook requests; queue for async processing
Sensitive data in transit HTTPS only; verify TLS certificate of webhook consumer
SSRF from webhook URLs Validate registered URLs against deny-list (RFC 1918, cloud IMDS)

Authorization Patterns

RBAC (Role-Based Access Control)

Users are assigned roles; roles map to permissions. Simple, well-understood, widely adopted.

Roles:         admin, editor, viewer
Permissions:   orders:read, orders:write, orders:delete, users:manage
Mapping:
  admin  → orders:read, orders:write, orders:delete, users:manage
  editor → orders:read, orders:write
  viewer → orders:read
# Middleware check
def require_permission(permission: str):
    def decorator(func):
        @wraps(func)
        async def wrapper(request, *args, **kwargs):
            user = request.state.user
            if permission not in user.permissions:
                raise HTTPException(403, "Insufficient permissions")
            return await func(request, *args, **kwargs)
        return wrapper
    return decorator

@app.delete("/orders/{order_id}")
@require_permission("orders:delete")
async def delete_order(order_id: str):
    ...

Limitation: RBAC doesn't handle contextual decisions well (e.g., "can edit only their own orders" or "can access only orders from their department").

ABAC (Attribute-Based Access Control)

Policies evaluate attributes of the user, resource, action, and environment at decision time.

Policy: ALLOW if
  user.department == resource.department AND
  action == "read" AND
  environment.time BETWEEN 09:00 AND 18:00

More expressive than RBAC but more complex to implement and audit. Used by AWS IAM, Google Cloud IAM, Azure RBAC.

ReBAC (Relationship-Based Access Control)

Authorization based on the relationship between user and resource, not just roles. Used by Google Zanzibar (Carta, Warrant, OpenFGA, SpiceDB).

Tuples:
  document:budget-2026#viewer@user:alice
  document:budget-2026#editor@user:bob
  folder:finance#viewer@group:accounting

Check: can user:alice view document:budget-2026?
→ YES (direct viewer relationship)

Best for document-sharing, multi-tenant SaaS, social graph-based permissions.


Transport Security

TLS Configuration

# Nginx — modern TLS config
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_stapling on;
ssl_stapling_verify on;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

TLS checklist: - TLS 1.2 minimum, TLS 1.3 preferred (faster handshake, forward secrecy built-in) - Disable SSLv3, TLS 1.0, TLS 1.1 - HSTS with long max-age, includeSubDomains, and preload - OCSP stapling for certificate verification performance - Certificate transparency (CT) logs — detect misissued certificates - Automate certificate rotation (Let's Encrypt / cert-manager in Kubernetes)

Certificate Pinning

Pin expected server certificate or public key hash in the client to prevent MITM attacks via compromised CAs.

# Get pin hash from certificate
openssl x509 -in server.crt -pubkey -noout | \
  openssl pkey -pubin -outform der | \
  openssl dgst -sha256 -binary | base64

Certificate Pinning Trade-offs

Pinning increases security against CA compromise but creates operational risk — certificate rotation requires synchronized client updates. Mobile apps with pinning must ship updates before certificate expiry. Consider using backup pins and gradual rollout.


API Security Testing

Automated Security Scanning

Tool Type Target
OWASP ZAP DAST (dynamic) REST, GraphQL
Burp Suite DAST (proxy-based) REST, GraphQL, WebSocket
Nuclei Template-based scanner Any HTTP API
Semgrep SAST (static) Source code patterns
Snyk API Dependency + DAST REST APIs
GraphQL Cop GraphQL-specific Introspection, complexity, injection
grpc-audit gRPC-specific Auth, TLS, message validation

Security Testing Checklist

# 1. Test BOLA — access another user's resource with your token
curl -H "Authorization: Bearer USER_A_TOKEN" \
  https://api.example.com/v2/orders/USER_B_ORDER_ID

# 2. Test BFLA — call admin endpoint with regular user token
curl -X DELETE -H "Authorization: Bearer REGULAR_TOKEN" \
  https://api.example.com/v2/admin/users/user_456

# 3. Test mass assignment — send privileged fields
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "admin", "isAdmin": true}' \
  https://api.example.com/v2/users/me

# 4. Test rate limiting — burst requests
for i in {1..100}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    https://api.example.com/v2/auth/login \
    -d '{"email":"[email protected]","password":"wrong"}'
done

# 5. Test SSRF — webhook URL pointing to internal
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "http://169.254.169.254/latest/meta-data/"}' \
  https://api.example.com/v2/webhooks

# 6. Test excessive data — request huge page
curl "https://api.example.com/v2/users?limit=999999"

# 7. Test GraphQL introspection in production
curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ __schema { types { name } } }"}'

# 8. Test JWT none algorithm
# Create token with alg:none, empty signature
echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '=' > /tmp/jwt_header
echo -n '{"sub":"admin","role":"admin"}' | base64 | tr -d '=' > /tmp/jwt_payload
JWT="$(cat /tmp/jwt_header).$(cat /tmp/jwt_payload)."
curl -H "Authorization: Bearer $JWT" https://api.example.com/v2/users/me

Sources

OWASP

JWT & OAuth

GraphQL Security

Authorization Frameworks

Tools