API Security Review
A disciplined, repeatable methodology for reviewing a REST or GraphQL API against the OWASP API Security Top 10 (2023) — Broken Object Level Authorization, Broken Authentication, Broken Object Property Level Authorization, Unrestricted Resource Consumption, Broken Function Level Authorization, Unrestricted Access to Sensitive Business Flows, Server-Side Request Forgery, Security Misconfiguration, Improper Inventory Management, and Unsafe Consumption of APIs — plus the cross-cutting auth, CORS, and GraphQL checks that account for most real-world breaches. BOLA alone is roughly 40% of observed API attacks.
The skill takes an OpenAPI spec, an endpoint diff, a request/response sample, or a chunk of API source, and returns severity-rated findings with curl test snippets and code-level fixes, plus a coverage matrix so callers see what was actually checked.
Honest scope & limits. I cannot hit a live server. I reason over what you provide — an OpenAPI spec, source code, request/response samples, or descriptions. The output is leads + a hardening plan, not a substitute for a professional penetration test or a live DAST run. Not legal or compliance advice; PCI / HIPAA / SOC 2 attestation requires qualified auditors.
When to Activate
Activate when the user:
- Shares an OpenAPI / Swagger spec (
openapi.yaml,swagger.json) and asks for a security pass. - Pastes REST or GraphQL endpoint source code (FastAPI, Flask, Express, NestJS, Rails, Django REST Framework, Spring, Go) and asks if it's safe to ship.
- Drops a pull-request diff touching API code, routes, auth middleware, an API gateway config, or CORS settings.
- Shares a request/response sample (curl, HAR, Postman) and asks "is this API secure?" or "what's leaking here?".
- Mentions any of: OWASP API Top 10, BOLA, broken auth, mass assignment, JWT, OAuth, OIDC, CORS, SSRF, rate limit, GraphQL depth/complexity, security headers.
Step 1: Intake & Scope
Establish the review's scope before saying anything about safety.
- What are we reviewing?
- (A) An OpenAPI / Swagger spec — full or excerpt.
- (B) Endpoint source code — one route, a module, or the whole service.
- (C) A PR / diff touching API code.
- (D) A request/response sample — curl, HAR, browser network capture.
- (E) An API gateway config — Kong, Apigee, AWS API Gateway, NGINX, Envoy.
- Stack. Language & framework: FastAPI, Flask, Django REST Framework, Express, NestJS, Hapi, Rails, Spring (Boot/MVC), ASP.NET, Go (gin/echo/chi). Different idioms hide different bugs.
- Auth model. JWT (HS / RS / ES) · OAuth 2.0 / OIDC (Authorization Code + PKCE, Client Credentials, Implicit — deprecated) · session cookies · API keys · mTLS · signed requests. Where is auth enforced — gateway, middleware, or per-route?
- REST · GraphQL · both? GraphQL needs extra checks (introspection, depth, complexity, per-resolver authz).
- Exposure. Public internet · partner-only (API key + IP allowlist) · internal (assumed trusted — verify) · machine-to-machine.
- What can you share? The spec? The route handler? A few sample requests? The auth middleware? Without artefacts, ask before guessing.
Do not proceed until you know what is in scope, which stack, and the auth model.
Step 2: Severity Model
Rate every finding by blast radius × likelihood.
| Severity | Meaning |
|---|---|
| 🔴 Critical | Full account or data takeover, mass PII leak, RCE, privilege escalation. Block deploy. |
| 🟠 High | Auth bypass on a single account, partial data leak, exploitable BOLA on a sensitive resource. |
| 🟡 Medium | DoS via missing rate limit, info leak (stack trace, version banner), CSRF on a non-critical flow. |
| 🟢 Low | Hardening gap on top of a strong default (missing one security header, weak cipher coexisting with a strong default). |
| ℹ️ Info | Best-practice nits, documentation gaps, naming hygiene. |
Step 3: OWASP API Top 10 (2023) — Review Passes
Use the exact IDs and names. For each: what it is · how to spot it · exploit scenario · fix. Cite each finding in your report by its OWASP ID.
API1:2023 Broken Object Level Authorization
What it is. An endpoint operates on
/orders/{id},/users/{userId},/files/{fileId}and authenticates the caller — but never checks that the caller owns that object. ~40% of all API attacks. Unchanged from 2019 because it remains the dominant API bug class.Spot it. Any path with
{id}/{uuid}/ numeric IDs where the handler loads the object by id alone, with noWHERE owner_id = callerfilter and no explicit ownership check. Sequential or guessable IDs (incrementing integers) amplify it.Exploit. Alice's token, Bob's
userIdin the URL → Bob's data.Fix. Explicit owner check at the handler, deny-by-default:
# FastAPI / SQLAlchemy obj = db.get(Order, order_id) if obj is None or obj.owner_id != request.user.id: raise HTTPException(status_code=404) # 404, not 403, to avoid id enumerationOr push it into the query:
Order.objects.filter(id=order_id, owner_id=user.id).first(). Prefer opaque, unguessable IDs (UUIDv4) over sequential integers so enumeration is harder even if a check is missed.
API2:2023 Broken Authentication
What it is. Weak login or token validation lets attackers impersonate users — credential stuffing, brute force, predictable session IDs, weak password reset, and JWT/OAuth misuse.
JWT pitfalls (check every JWT system).
alg: noneaccepted by the server.- Algorithm confusion — the server uses HMAC verify with the RSA public key as the secret (attacker signs an HS256 token with the public key, server accepts it).
- Weak HS256 secret (dictionary-crackable).
- No
iss,aud,expverification. - Long-lived tokens with no revocation list and no rotation.
- Sensitive PII inside JWT claims (it's returned to the client).
- JWT used as both session and authorization bearer with no scope check.
OAuth 2.0 / OIDC.
- Authorization Code flow without PKCE for public clients (SPAs, mobile).
- Open-redirect on
redirect_uri(always allowlist exact URIs). - Implicit flow still in use — deprecated.
- Missing
state/nonce→ CSRF on the auth code grant. - Scope check missing at the resource server.
Sessions / cookies. No
Secure,HttpOnly,SameSite=Lax|Strict. Session ID in the URL. No re-auth for sensitive operations (password change, email change, payment).Spot it.
jwt.decode(token, key)with noalgorithms=allowlist;verify_signature=False; noaud/issarguments; HS256 in a public-client setup; cookie set withoutSecure/HttpOnly/SameSite.Fix (PyJWT).
import jwt payload = jwt.decode( token, public_key, algorithms=["RS256"], # pin the algorithm — block alg=none + confusion audience="my-api", # verify aud issuer="https://auth.example", # verify iss options={"require": ["exp", "iat", "iss", "aud"]}, )Use short-lived access tokens (5–15 min) + refresh tokens with rotation, add a token revocation list (
jti), and never put PII in claims you wouldn't log.
API3:2023 Broken Object Property Level Authorization
What it is. Two sub-classes merged: excessive data exposure (the response includes properties the caller shouldn't see —
password_hash,internal_flags,mfa_secret) and mass assignment (the request body writes properties the caller shouldn't touch —isAdmin,balance,tenant_id).Spot it.
return user.__dict__/return user.to_dict()/ serializing the whole ORM row.User(**request.json)/user.update(**body)/ Railsuser.update_attributes(params[:user])without strong params. No request/response schema with an explicit allowlist.Exploit.
PATCH /users/mewith{"isAdmin": true, "tenantId": 42}and the user is now an admin in someone else's tenant.Fix. Explicit allowlist schemas on input and output:
# Pydantic — explicit, allow-list, no extras class UserUpdateIn(BaseModel): display_name: str | None = None email: EmailStr | None = None model_config = ConfigDict(extra="forbid") # reject unknown fields class UserOut(BaseModel): id: UUID display_name: str email: EmailStr # NOTE: no password_hash, no is_admin, no internal flags// Zod — strict() rejects extra keys const UserUpdateIn = z.object({ displayName: z.string().min(1).max(80).optional(), email: z.string().email().optional(), }).strict();Never return the raw ORM row. Map to a response schema and
exclude/omitsensitive fields by default.
API4:2023 Unrestricted Resource Consumption
What it is. Renamed from Lack of Rate Limiting. No rate limit, no payload size cap, no pagination cap, unbounded queries → DoS or cost blow-up (DB, LLM, third-party APIs).
Pragmatic checklist.
- Token-keyed > IP-keyed. NAT and CDNs flatten IPs; token-keyed limits track the real principal.
- Separate buckets: per-user, per-IP, per-endpoint, per-tenant.
- Payload size cap (HTTP body), JSON depth and array-length caps.
- Pagination: enforce
limit ≤ 100; never accept arbitrarylimit. - Long-running endpoint timeouts.
- Cost budgets on expensive resources (DB scans, LLM calls, third-party metering).
Spot it. No middleware/limiter on routes.
limit = int(request.args["limit"])with no max. No body-size middleware. Unlimited file uploads. Heavy joins with no pagination.Fix.
# FastAPI + slowapi — per-user token-keyed limiter from slowapi import Limiter from slowapi.util import get_remote_address def key_by_user(request): return request.state.user.id if hasattr(request.state, "user") else get_remote_address(request) limiter = Limiter(key_func=key_by_user, default_limits=["60/minute"]) @app.get("/search") @limiter.limit("20/minute") def search(q: str, limit: int = Query(20, ge=1, le=100)): # cap pagination ...// Express + express-rate-limit, per-user import rateLimit from "express-rate-limit"; app.use(express.json({ limit: "100kb" })); // body cap app.use("/api/", rateLimit({ windowMs: 60_000, max: 60, keyGenerator: (req) => req.user?.id || req.ip, // token-keyed when possible }));
API5:2023 Broken Function Level Authorization
What it is. Admin / privileged functions are reachable by non-admins — the URL is "secret", the role check lives in the UI, or the check is on the wrong route.
POST /admin/usersaccepts a normal user token.Spot it. No route-level role check;
if user.is_adminonly in the frontend; admin endpoints sharing the same router as user endpoints with no separate guard; "hidden" admin URLs that nonetheless reach the handler.Exploit. A regular user calls
POST /admin/usersand creates an admin account, or callsDELETE /users/{id}and erases anyone.Fix. Deny by default, allowlist roles, enforce at the route:
# FastAPI dependency-injected guard, applied per-route def require_admin(user: User = Depends(current_user)) -> User: if "admin" not in user.roles: raise HTTPException(status_code=403) return user @router.post("/admin/users", dependencies=[Depends(require_admin)]) def create_user(...): ...Make the guard the default, then explicitly mark public routes — not the other way around. Cover
DELETE/PUT/PATCHon every admin object.
API6:2023 Unrestricted Access to Sensitive Business Flows
- What it is. New in 2023. Automation abuses the business logic itself — buy-all-tickets bots, reward farming, coupon stacking, mass account creation, mass password reset, mass follow/transfer/withdraw. Each individual request is "legal"; the volume is the abuse.
- Sensitive flows to protect. Account creation, password reset, login, coupon/promo redemption, purchase, review/comment, follow, transfer, withdraw, refer-a-friend rewards.
- Spot it. A
POST /signupwith no CAPTCHA, no device-fingerprint, no velocity check, no business-logic cap. A coupon endpoint with no per-user limit. A withdraw endpoint with no daily cap. - Fix. Defense-in-depth at the business layer, not just rate limit:
- CAPTCHA (hCaptcha, Turnstile) on high-value flows.
- Device fingerprint + behavioural anomaly detection.
- Velocity checks (per-user, per-IP, per-tenant, per-device).
- Hard business-logic caps ("max 1 coupon per account per day", "max 3 password resets per hour per email", "max $X withdrawn per day").
API7:2023 Server-Side Request Forgery
What it is. New in 2023. A URL or hostname comes from the user, and the server fetches it. Attackers point it at internal services — cloud metadata (
169.254.169.254), internal admin panels, internal databases, neighbour pods.Spot it. Webhook subscription endpoints, "import from URL", PDF/HTML renderers, image proxies, link-preview generators, RSS importers.
Exploit.
POST /webhooks/fetch {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}→ AWS IAM creds leaked back in the response.Fix. Strict URL allowlist; resolve DNS before fetch; reject private/ link-local/loopback ranges; pin to HTTP(S); use a separate egress network if possible.
import ipaddress, socket from urllib.parse import urlparse BLOCKED_NETS = [ipaddress.ip_network(n) for n in ( "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16", "fc00::/7", "::1/128", "fe80::/10", )] def safe_url(url: str) -> str: u = urlparse(url) if u.scheme not in ("http", "https"): raise ValueError("scheme not allowed") ip = ipaddress.ip_address(socket.gethostbyname(u.hostname)) if any(ip in net for net in BLOCKED_NETS): raise ValueError("private/loopback/link-local not allowed") return urlBonus: defend against DNS rebinding — resolve once, fetch by the resolved IP (with the original Host header), so the second resolution can't flip to a private address after the check.
API8:2023 Security Misconfiguration
What it is. Default credentials, verbose errors, missing TLS, open admin panels, debug mode in production, missing security headers, permissive CORS, loose content-type handling.
Spot it.
DEBUG = True,app.run(debug=True), stack traces in production responses,Server: gunicorn/20.x/X-Powered-By: Express, missing HSTS / CSP /X-Content-Type-Options,Access-Control-Allow-Origin: *with credentials.Fix. A baseline header set, strict content-type, generic error pages:
@app.middleware("http") async def security_headers(request, call_next): resp = await call_next(request) resp.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload" resp.headers["X-Content-Type-Options"] = "nosniff" resp.headers["Referrer-Policy"] = "no-referrer" resp.headers["Content-Security-Policy"] = "frame-ancestors 'none'" resp.headers["Permissions-Policy"] = "interest-cohort=()" return respTLS 1.2+ only, modern ciphers, no weak fallbacks. Generic error responses to clients; rich logs to the server side. Strict content-type check — reject anything not
application/jsonon JSON endpoints.
API9:2023 Improper Inventory Management
- What it is. Renamed from Improper Asset Management. Forgotten v1 /
staging / test /
internalendpoints with weaker auth still reachable. Old API versions kept running for "compatibility" with no deprecation plan. Undocumented endpoints that the security team doesn't know exist. - Spot it. Routes labelled
v0,v1,legacy,internal,debug,stagingreachable from the public domain. No OpenAPI spec — or one that drifts from reality. NoSunset/Deprecationheaders on retiring endpoints. - Fix. OpenAPI is the source of truth. Generate it from code (FastAPI,
NestJS, drf-spectacular) so it can't drift. Catalogue every endpoint, every
version, every environment. Send
SunsetandDeprecationheaders on retiring versions and put a hard kill-date on them. Blockinternal/debug/adminpaths at the edge (gateway/WAF) unless explicitly allowed.
API10:2023 Unsafe Consumption of APIs
- What it is. New in 2023. Your API trusts a third-party API's response without validation — and that response can be hostile (compromised vendor, malicious user-controlled webhook, untrusted SaaS). Subsumes much of the old "injection" class.
- Spot it.
requests.get(vendor_url).json()shoved straight into your DB or templated into another query. Deserializing untrusted blobs (pickle, Java native serialization, YAML!!tags). Forwarding third-party error bodies verbatim to clients. SQL/NoSQL/LDAP/XPath/command strings built by string-formatting third-party data. - Fix. Treat third-party responses as untrusted input:
- Validate against a strict response schema (JSON Schema / Pydantic / Zod).
- Parametrize every SQL/NoSQL/LDAP/XPath query — never string-format.
- Never
pickle.loads/ Java native deserialize data you didn't write. - Use
yaml.safe_load, notyaml.load. - Enforce TLS, certificate verification, and timeouts on every outbound call.
- Don't blindly forward error bodies — wrap them.
Step 4: Cross-Cutting Checks
These slice across multiple OWASP items but warrant their own pass.
JWT deep-dive (one more time, explicitly)
- ✅ Pin
algorithms=[…]to exactly what you issue (RS256 or ES256 — not "any"). - ✅ Verify
iss,aud,exp,nbf,iat; require their presence. - ✅ Strong, rotated signing keys; HS256 only for server-to-server with a high-entropy secret stored in a secret manager.
- ✅ Short-lived access tokens + refresh-token rotation; revocation by
jti. - ✅ No PII in claims you wouldn't log. Scopes/roles checked per-route.
- 🚫 No
alg: none. No HMAC verification with the RSA public key. No long-lived bearer tokens with no revocation path.
CORS attack patterns
- 🚫
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true(browsers reject — but reflection variants slip through anyway). - 🚫 Origin reflection — the server echoes whatever
Originthe client sends. A malicious site's origin gets reflected, and an authenticated cross-origin request is wide open. - 🚫
nullorigin accepted (sandboxed iframes, file://, some redirects sendOrigin: null). - 🚫 Pre-flight not enforced server-side — the server actually executes the unsafe request even though the browser would block the response read.
- ✅ Explicit origin allowlist; per-origin
Allow-Credentials; rejectnull; validate theOriginheader on every state-changing request.
GraphQL specifics
- ✅ Introspection disabled in production (or restricted to authorized operators).
- ✅ Query depth limit (e.g. ≤ 10) and complexity / cost analysis limit.
- ✅ Field-level authorization at every resolver — in GraphQL, BOLA is
per-field, not per-endpoint. A
Usertype'semailmay be public;phoneis not. - ✅ Cap aliases + batching (a single HTTP request can contain many expensive operations).
- ✅ Watch resolver-level N+1 — DataLoader or batch resolvers prevent shaped queries from melting the DB.
- ✅ Persisted queries (or APQ with an allowlist) for high-trust first-party clients; arbitrary queries only for partners that need them.
Step 5: Red-Flags Quick Scan
Any one of these flips the review toward 🟠 or 🔴 — name it and find the others nearby:
GET /users/{id}returns all user fields (PII,password_hash, internal flags). → API3.PATCH /users/{id}accepts an arbitrary body with no schema (mass assignment). → API3.- JWT verified with
verify=False/verify_signature=False, or noalgorithms=allowlist. → API2. - CORS origin reflection (
Access-Control-Allow-Origin: <whatever the request sent>). → API8. - No
401/403on/admin/*for a non-admin token. → API5. - Rate limit only on
/login, nowhere else (DoS, scraping, BOLA enumeration all uncapped). → API4. - Endpoint accepts a URL in the body and fetches it server-side, with no allowlist. → API7.
- GraphQL introspection enabled on prod (
/graphqlreturns__schema). → API9. - Internal endpoints under
/v0/internal/*reachable from the public domain. → API9.
Step 6: Output Format — the Review Report
Lead with a verdict banner, then findings, then a coverage matrix, then suggested test artefacts and the disclaimer.
Verdict banner (pick one)
- ✅ Hardened — no Critical or High findings; baseline headers, auth, and rate limits in place.
- 🟡 Multiple Medium issues — fix before prod scale-up.
- 🟠 High risk — patch the listed items before the next deploy.
- ⛔ Critical — block deploy. One or more 🔴 findings (auth bypass, BOLA on PII, SSRF, mass assignment of privilege).
Follow with one line of why (e.g. "BOLA on /users/{userId} and mass
assignment of isAdmin on PATCH /users/{userId}").
Findings table
| # | OWASP ID | Endpoint / feature | Severity | One-liner |
|---|---|---|---|---|
| 1 | API1:2023 | GET /users/{userId} |
🔴 Critical | No owner check; any authenticated user reads any user. |
| 2 | API3:2023 | PATCH /users/{userId} |
🔴 Critical | Mass assignment — accepts isAdmin. |
| 3 | API8:2023 | global | 🟡 Medium | Missing HSTS / CSP / X-Content-Type-Options. |
Per-finding detail
For each finding, write:
Description — what's wrong, in one short paragraph.
Impact — what an attacker gets.
Curl test snippet — a single command another engineer can run to reproduce:
# API1:2023 — BOLA on /users/{userId} curl -i -H "Authorization: Bearer $ALICE_TOKEN" \ https://api.example.com/users/$BOB_USER_ID # Expect: 403 / 404 (object hidden). Vulnerable: 200 + Bob's record.Code-level fix — the exact snippet from Step 3 for that OWASP item, adapted to the user's stack.
References — link to the OWASP API Top 10 2023 page for that ID.
Coverage matrix
So the caller knows what was actually checked vs not reviewed:
| OWASP ID | Name | Result |
|---|---|---|
| API1:2023 | Broken Object Level Authorization | ❌ fail (finding #1) |
| API2:2023 | Broken Authentication | ✅ pass |
| API3:2023 | Broken Object Property Level Authorization | ❌ fail (finding #2) |
| API4:2023 | Unrestricted Resource Consumption | 🟡 partial (rate limit OK, no body cap) |
| API5:2023 | Broken Function Level Authorization | ✅ pass |
| API6:2023 | Unrestricted Access to Sensitive Business Flows | ⚪ not reviewed (need flow inventory) |
| API7:2023 | Server-Side Request Forgery | ⚪ n/a (no outbound fetch in scope) |
| API8:2023 | Security Misconfiguration | ❌ fail (finding #3) |
| API9:2023 | Improper Inventory Management | ⚪ not reviewed (spec only) |
| API10:2023 | Unsafe Consumption of APIs | ⚪ n/a (no third-party calls in scope) |
Suggested test artefacts
A small set the caller can drop into CI right now.
# tests/test_bola.py — pytest regression for finding #1
def test_bola_users_endpoint(client, alice_token, bob_user_id):
r = client.get(f"/users/{bob_user_id}", headers={"Authorization": f"Bearer {alice_token}"})
assert r.status_code in (403, 404), f"BOLA: alice could read bob, got {r.status_code} {r.text}"
# .github/workflows/api-security.yml
name: api-security
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 42Crunch API Security Audit
uses: 42Crunch/api-security-audit-action@v4
with:
api-token: ${{ secrets.FORTYTWO_CRUNCH_TOKEN }}
min-score: 75
- name: OWASP ZAP baseline
uses: zaproxy/action-baseline@v0.12.0
with:
target: https://staging.api.example.com
- name: Schemathesis property-based tests
run: |
pip install schemathesis
schemathesis run --checks all openapi.yaml \
--base-url https://staging.api.example.com
Disclaimer (always include)
Educational security guidance — not a substitute for a professional penetration test or a live DAST run, and not legal or compliance advice. This review is static reasoning over what you provided (spec, source, or samples); it cannot hit a live server, observe runtime behaviour, or attest to PCI / HIPAA / SOC 2 conformance — those require qualified auditors. Fix the findings, then run a live scan (42Crunch, OWASP ZAP, Schemathesis, Burp) and, for high-value services, commission a third-party pen-test.
Related Viprasol Skills
code-review-skill— general correctness / security / performance review of the same PR with prioritized findings.smart-contract-audit— if the API fronts an on-chain protocol, audit the Solidity/EVM contracts behind it.
Not affiliated with or endorsed by Anthropic.