API Security skill for Claude Code
Prove every endpoint denies the wrong user. Claude works through the OWASP API Security Top 10 with the checks that catch real breaches: queries scoped to the caller, strict input and output schemas against mass assignment and data leaks, default-deny function checks, page and body caps, and GraphQL cost limits. A bundled matrix script calls every endpoint as several users on staging and fails on any cross-account access.
- name
api-security - size176 lines · ≈2,100 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Secures and tests REST and GraphQL APIs against the OWASP API Security Top 10 — object-level authorization (BOLA/IDOR) with cross-user test matrices, property-level authorization and mass assignment, function-level authorization, rate limiting and resource caps, sensitive business-flow abuse, SSRF, inventory of shadow endpoints, and unsafe consumption of third-party APIs. Use when building or reviewing API endpoints, adding authorization checks, fixing an IDOR finding, setting rate limits, hardening GraphQL, or preparing an API for external clients. Try asking
- Check our REST API for IDOR — can one customer read another's orders?
- Our user update endpoint lets people set is_admin. Fix it everywhere
- Harden our public GraphQL API against abusive queries
Use it when
- Building or reviewing endpoints that read or modify user-owned objects
- A pentest or bug bounty reported IDOR or mass assignment
- Opening an API to partners, mobile apps, or the public
- Setting resource caps and abuse limits on expensive operations
Skip it when
- You are designing resource names, status codes, and pagination style — use rest-api-design instead
- The task is choosing a rate-limiting algorithm or store — use rate-limiting instead
How to check the result
The authorization matrix passes for every object-scoped endpoint across two accounts and a low-privilege role, and update requests carrying role or account_id leave stored values unchanged.
The common mistake it prevents
Scoping the main list endpoint by account while bulk, export, search, or GraphQL node lookups still fetch objects by raw ID.
Done well means: every endpoint authenticates the caller, authorizes the action and the specific object and each writable property, bounds the resources a request can consume, returns only fields the caller may see, and appears in an inventory with an owner. Prove authorization with a cross-user test matrix, not by reading code alone.
OWASP API Security Top 10 (2023) — what to check
| ID | Risk | Check |
|---|---|---|
| API1 | Broken Object Level Authorization | Every lookup by ID is scoped to the caller (owner, tenant, ACL) |
| API2 | Broken Authentication | Token validation complete; no auth on “internal” routes exposed publicly; credential stuffing limits |
| API3 | Broken Object Property Level Authorization | Writes accept only allowlisted fields (mass assignment); responses omit fields the caller may not see |
| API4 | Unrestricted Resource Consumption | Page size caps, body size limits, timeouts, rate limits, upload caps, GraphQL depth/cost limits, paid third-party calls capped |
| API5 | Broken Function Level Authorization | Admin/privileged operations check role server-side; method switches (GET→DELETE) re-check |
| API6 | Unrestricted Access to Sensitive Business Flows | Signup, checkout, coupon, booking, invite flows protected from automation at scale |
| API7 | Server Side Request Forgery | User-supplied URLs fetched safely (see input-validation) |
| API8 | Security Misconfiguration | CORS, verbose errors, missing TLS, default creds, permissive HTTP methods |
| API9 | Improper Inventory Management | Old versions (/v1), debug, staging, and undocumented endpoints still reachable |
| API10 | Unsafe Consumption of APIs | Third-party responses validated, timeouts set, redirects not blindly followed, TLS verified |
Workflow
- [ ] 1. Build the endpoint inventory (method, path, auth, roles, objects touched)
- [ ] 2. Review the authorization design: where object/function/property checks live
- [ ] 3. Fix BOLA and mass assignment at the data-access layer
- [ ] 4. Add resource limits and rate limits
- [ ] 5. Prove it with an authorization matrix test against local/staging
- [ ] 6. Retire or protect shadow and legacy endpoints
1. Inventory
Generate it from code or the OpenAPI spec, not memory:
# FastAPI / Django REST / Express examples — adjust to the framework
grep -rnE "@(router|app)\.(get|post|put|patch|delete)\(" --include=*.py .
grep -rnE "(router|app)\.(get|post|put|patch|delete)\(" --include=*.{js,ts} .
Compare with the spec and the gateway/ingress routes. Endpoints present in code or gateway but not in the spec are API9 findings.
2–3. Authorization
Object level (BOLA). Scope every query through the principal rather than checking after fetching:
# Django REST Framework
class InvoiceViewSet(viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
def get_queryset(self):
return Invoice.objects.filter(account=self.request.user.account)
// Prisma
const invoice = await prisma.invoice.findFirst({ where: { id, accountId: session.accountId } });
if (!invoice) return res.status(404).end();
- Return 404 for objects the caller cannot access (403 confirms existence).
- Random IDs (UUIDv4/v7) reduce enumeration but are not authorization.
- Nested routes (
/accounts/:a/invoices/:i): verify the invoice belongs to account:aand the caller belongs to:a. - Bulk endpoints (
ids=1,2,3), exports, search, and GraphQL node lookups (node(id:)) need the same scoping — they are the usual gaps.
Function level. Central policy with default deny (route decorators, middleware, OPA/Cedar/Casbin/CASL policies). Test that a member token receives 403 on every admin route.
Property level (mass assignment and over-exposure).
class InvoiceUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
note: str | None = None # status, total, account_id are NOT writable here
const InvoiceUpdate = z.object({ note: z.string().max(500).optional() }).strict();
- Never pass
req.bodydirectly toupdate()/save()/Object.assign(model, body). - Responses: serialize through explicit output schemas/DTOs; never return the ORM object
(leaks
password_hash, internal flags, other users’ emails in relations). - Role-dependent fields (e.g.
cost_pricefor staff only) need separate serializers.
4. Resource limits
| Control | Typical default |
|---|---|
| Request body size | 1 MB JSON (raise per route for uploads) |
| Page size | default 20–50, max 100; cursor pagination for large sets |
| Request timeout | 10–30 s at the edge; DB statement timeout below that |
| Rate limit, authenticated | Per user/API key, e.g. 600/min burst, lower on expensive routes |
| Rate limit, unauthenticated | Per IP, low; much lower on login, OTP, password reset, signup |
| Expensive operations (exports, LLM calls, SMS) | Per-account quotas and spend caps |
| GraphQL | Max depth (e.g. 10), query cost/complexity limits, max aliases/batch size, persisted queries for first-party clients |
Return 429 Too Many Requests with Retry-After. Implement limits at the gateway for
coarse protection and in the app for per-user/business limits (see rate-limiting for
algorithms).
5. Prove authorization with a matrix test
Create two users in different accounts (plus an anonymous identity and a low-privilege role), then request every object-scoped endpoint as each identity. Run against a local or staging environment you control:
python3 scripts/authz_matrix.py matrix.json # human-readable table
python3 scripts/authz_matrix.py matrix.json --json # for CI artifacts
matrix.json lists base_url, identities (headers; tokens may be $ENV_VAR
references so they stay out of files), and requests with an allow list. The script
fails any case where a non-allowed identity gets 2xx or an allowed identity is refused,
notes 403-vs-404 existence leaks, and exits 1 on violations so it can gate CI. Keep the
matrix next to the API tests and add a row whenever an endpoint is added.
Prefer encoding the same matrix in the project’s own test framework (pytest
parametrize, Jest test.each) once it stabilizes; the script is the quick first pass.
6. Inventory hygiene
- Remove or gate old versions; if
/v1must live, give it the same auth middleware and limits as/v2. - Disable GraphQL introspection and API explorers (Swagger UI) on public production unless the API is public by design.
- Staging and preview environments must not share production data or credentials.
Other API-specific traps
- Authentication on every route by default. Opt out explicitly for public routes; a forgotten decorator should mean “denied”, not “public”.
- API keys: hash at rest (store a prefix for lookup), scope to permissions, support rotation and multiple active keys, never in query strings (they end up in logs).
- Webhooks you send: sign payloads (HMAC with timestamp), and treat the customer’s URL as an SSRF sink.
- Third-party responses (API10): validate with a schema, set timeouts, cap response size, do not follow redirects to arbitrary hosts.
- Errors: generic messages with a correlation ID; no stack traces, SQL, or internal hostnames.
- Idempotency: payment and order creation accept an
Idempotency-Keyso retries and replays do not double-charge (see idempotency-retries).
Output: findings format
### [H-2] BOLA on GET /api/v2/invoices/{id}
**Evidence:** matrix row "bob reads alice invoice" returned 200; handler at
api/invoices.py:57 loads by id only.
**Impact:** any authenticated user can read any invoice (sequential IDs).
**Fix:** scope queryset by account (diff attached); return 404 on miss.
**Regression test:** tests/api/test_invoice_authz.py::test_cross_account_read_denied
Verification
- The authorization matrix passes for every object-scoped endpoint with at least two accounts and one low-privilege role.
- Mass-assignment tests: sending
role,account_id,is_admin,totalin update bodies is rejected or ignored and the stored values do not change. - Load a page with
limit=100000and an oversized body; confirm caps apply. - Inventory diff (code vs. spec vs. gateway) is empty or each extra route is justified.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/authz_matrix.py
#!/usr/bin/env python3
"""Cross-user authorization (BOLA/IDOR) matrix test for an API you own.
Sends each configured request as every configured identity and compares the actual
status with the expected outcome. Run against local or staging environments only.
Usage:
python3 authz_matrix.py matrix.json # table output
python3 authz_matrix.py matrix.json --json # machine-readable
matrix.json:
{
"base_url": "http://localhost:8000",
"identities": {
"alice": {"Authorization": "Bearer <alice-token>"},
"bob": {"Authorization": "Bearer <bob-token>"},
"anon": {}
},
"requests": [
{"name": "alice reads own invoice", "method": "GET", "path": "/api/invoices/101",
"allow": ["alice"]},
{"name": "update invoice", "method": "PATCH", "path": "/api/invoices/101",
"body": {"note": "x"}, "allow": ["alice"]}
]
}
An identity in "allow" must get a 2xx; every other identity must get 401/403/404.
Exit codes: 0 = all as expected, 1 = at least one violation, 2 = config/network error.
Standard library only (Python 3.10+). Tokens may also be given as "$ENV_VAR" strings.
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
DENIED = {401, 403, 404}
def expand(value: str) -> str:
return os.environ.get(value[1:], "") if isinstance(value, str) and value.startswith("$") else value
def send(base, req, headers):
data = None
hdrs = {k: expand(v) for k, v in headers.items()}
if "body" in req:
data = json.dumps(req["body"]).encode()
hdrs.setdefault("Content-Type", "application/json")
r = urllib.request.Request(base.rstrip("/") + req["path"], data=data,
method=req.get("method", "GET").upper(), headers=hdrs)
try:
with urllib.request.urlopen(r, timeout=15) as resp:
return resp.status
except urllib.error.HTTPError as err:
return err.code
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("matrix")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
try:
cfg = json.load(open(args.matrix))
base, ids, reqs = cfg["base_url"], cfg["identities"], cfg["requests"]
except (OSError, KeyError, json.JSONDecodeError) as exc:
print(f"config error: {exc}", file=sys.stderr)
return 2
rows, violations = [], 0
for req in reqs:
allow = set(req.get("allow", []))
for ident, headers in ids.items():
try:
status = send(base, req, headers)
except Exception as exc: # noqa: BLE001
print(f"network error on {req['name']} as {ident}: {exc}", file=sys.stderr)
return 2
expected = "allow" if ident in allow else "deny"
ok = (200 <= status < 300) if expected == "allow" else (status in DENIED)
note = ""
if not ok and expected == "deny" and 200 <= status < 300:
note = "VIOLATION: access granted"
elif not ok and expected == "deny":
note = f"unexpected status (expected one of {sorted(DENIED)})"
elif not ok:
note = "allowed identity was refused"
if expected == "deny" and status == 403 and ok:
note = "403 confirms existence; prefer 404 for object access"
violations += not ok
rows.append({"request": req["name"], "method": req.get("method", "GET"), "path": req["path"],
"identity": ident, "expected": expected, "status": status, "ok": ok, "note": note})
if args.json:
print(json.dumps({"violations": violations, "results": rows}, indent=2))
else:
for r in rows:
mark = "ok " if r["ok"] else "FAIL"
print(f"[{mark}] {r['method']:6} {r['path']:40} as {r['identity']:10} "
f"expected {r['expected']:5} got {r['status']} {r['note']}")
print(f"\n{violations} violation(s) across {len(rows)} checks")
return 1 if violations else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.