Security No. 268

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.

  • nameapi-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

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.

~/.claude/skills/api-security/SKILL.md

--- name: api-security description: Secures and tests REST and GraphQL APIs against the OWASP API Security Top 10 — object-level authorization (BOLA/IDOR) with cross-user test… license: MIT ---

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

IDRiskCheck
API1Broken Object Level AuthorizationEvery lookup by ID is scoped to the caller (owner, tenant, ACL)
API2Broken AuthenticationToken validation complete; no auth on “internal” routes exposed publicly; credential stuffing limits
API3Broken Object Property Level AuthorizationWrites accept only allowlisted fields (mass assignment); responses omit fields the caller may not see
API4Unrestricted Resource ConsumptionPage size caps, body size limits, timeouts, rate limits, upload caps, GraphQL depth/cost limits, paid third-party calls capped
API5Broken Function Level AuthorizationAdmin/privileged operations check role server-side; method switches (GET→DELETE) re-check
API6Unrestricted Access to Sensitive Business FlowsSignup, checkout, coupon, booking, invite flows protected from automation at scale
API7Server Side Request ForgeryUser-supplied URLs fetched safely (see input-validation)
API8Security MisconfigurationCORS, verbose errors, missing TLS, default creds, permissive HTTP methods
API9Improper Inventory ManagementOld versions (/v1), debug, staging, and undocumented endpoints still reachable
API10Unsafe Consumption of APIsThird-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 :a and 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.body directly to update()/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_price for staff only) need separate serializers.

4. Resource limits

ControlTypical default
Request body size1 MB JSON (raise per route for uploads)
Page sizedefault 20–50, max 100; cursor pagination for large sets
Request timeout10–30 s at the edge; DB statement timeout below that
Rate limit, authenticatedPer user/API key, e.g. 600/min burst, lower on expensive routes
Rate limit, unauthenticatedPer IP, low; much lower on login, OTP, password reset, signup
Expensive operations (exports, LLM calls, SMS)Per-account quotas and spend caps
GraphQLMax 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 /v1 must 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-Key so 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, total in update bodies is rejected or ignored and the stored values do not change.
  • Load a page with limit=100000 and 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 108 lines · 4.1 KB
#!/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.

esc