api-security

An API has no UI to hide things behind.

SKILL.md

GitHub

API Security (Verbose)

Core Patterns

Object-Level Authorization

An API has no UI to hide things behind. Every identifier a client has ever seen is an identifier it can replay against every endpoint. The dominant API vulnerability class — broken object-level authorization — is not a missing authentication check; the attacker is fully authenticated as themselves.

# The check must be part of the lookup, not a step after it.
def load_invoice(invoice_id: int, principal: Principal) -> Invoice:
    row = (
        session.query(Invoice)
        .filter(Invoice.id == invoice_id)
        .filter(Invoice.tenant_id == principal.tenant_id)   # non-negotiable predicate
        .one_or_none()
    )
    if row is None:
        raise NotFound()          # 404, not 403 — do not confirm existence
    return row

The structural fix is to make the unscoped query impossible to write: a repository layer that only exposes for_tenant(tenant_id), or Postgres row-level security where the session variable carries the tenant.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

RLS is enforced by the database, so an ORM bug, a raw SQL report query, and an ad-hoc admin script all inherit the same boundary. Note the asymmetry: USING governs reads and updates of existing rows, WITH CHECK governs rows being written — omit WITH CHECK and a tenant can insert rows attributed to another.

Input as an Attack Surface

Validation is not about data quality here; it is about limiting what the attacker can express.

class TransferRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")   # unknown field -> 422, not ignored

    to_account: constr(pattern=r"^[0-9]{10}$")
    amount_cents: conint(gt=0, le=100_000_00)
    memo: constr(max_length=140) = ""

Three distinct failures this prevents:

Failure Mechanism Consequence
Mass assignment Body spread into model/ORM Client sets role, is_verified, balance
Type juggling Loose coercion of "0", [], null Comparison and truthiness checks invert
Resource exhaustion Unbounded strings, arrays, nesting Memory blowup, regex catastrophic backtracking

Validate at the boundary and pass the parsed object inward. Re-parsing the raw body later invites parser-differential bugs, where the validating parser and the consuming parser disagree about duplicate JSON keys or oversized integers.

Server-Side Request Forgery

Any parameter that becomes an outbound request is an SSRF sink: webhook registration, "import from URL", link unfurling, PDF/HTML rendering, XML with external entities.

BLOCKED = [ip_network(n) for n in (
    "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
    "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10",
)]

def assert_public(host: str) -> None:
    for info in socket.getaddrinfo(host, None):
        addr = ip_address(info[4][0])
        if any(addr in net for net in BLOCKED) or not addr.is_global:
            raise ValueError(f"blocked target: {addr}")

This is necessary and not sufficient. Validating a hostname then handing the URL to an HTTP client leaves a TOCTOU window — DNS rebinding returns a public address on the check and 169.254.169.254 on the connect. Robust designs pin the resolved address into the connection, disable redirects (or re-validate each hop), and put the egress behind a proxy with a destination allowlist. On AWS, enforcing IMDSv2 removes the highest-value SSRF target, since token retrieval requires a PUT that a naive proxied GET cannot perform.

Error and Response Hygiene

# ❌ Debug detail shipped to the caller
except IntegrityError as e:
    return {"error": str(e)}      # leaks table names, constraint names, values

# ✅ Correlate, don't disclose
except IntegrityError:
    ref = uuid4().hex
    log.exception("db integrity failure", extra={"ref": ref})
    return JSONResponse({"error": "conflict", "ref": ref}, status_code=409)

The same discipline applies to response bodies. Serializing the whole ORM object because "the frontend ignores the extra fields" ships password_digest, internal_notes, and other tenants' identifiers to anyone with curl. Define explicit response schemas.

Inventory and Lifecycle

You cannot secure endpoints you do not know about. Deprecated /v1 routes, staging hosts on public DNS, and internal debug handlers behind a "nobody-knows-the-URL" assumption are the ones with the stale validation logic. Enumerate routes from the framework's own router at build time and diff against the published spec:

routes = sorted(f"{m} {r.path}" for r in app.routes for m in sorted(r.methods))

Fail CI when a route exists that the OpenAPI document does not describe — the gap is exactly where review never happened. Give deprecated versions a hard sunset date and return Deprecation / Sunset response headers before removal.

Common Anti-Patterns

❌ Fetching by id, then checking ownership in the handler — one forgotten branch is a full cross-tenant read. ✅ Make tenant scope part of the query or enforce it in the database with RLS.

❌ Returning 403 for another user's resource — the status code itself confirms the object exists and is a free enumeration oracle. ✅ 404 for anything the caller may not see.

❌ Binding the request body directly to the persistence model. ✅ A separate, extra="forbid" input schema and a separate output schema.

❌ Trusting internal-only network position — one SSRF or one compromised pod puts the attacker inside that network. ✅ Authenticate service-to-service calls (mTLS or signed tokens) and authorize them as first-class principals.

❌ Rate limiting only unauthenticated traffic. ✅ Per-principal quotas on expensive authenticated endpoints too — export, search, and report generation are the usual denial-of-wallet vectors.

❌ Validating a URL string and then following redirects with the default client. ✅ Re-validate every hop, or route all egress through an allowlisting proxy.

❌ Logging the full request to debug an incident. ✅ Redact Authorization, cookies, tokens, and PII at the logger, not by convention.

API Security Checklist

  • Every object read/write is scoped by tenant or owner in the query itself
  • Not-authorized and not-found are indistinguishable to the caller
  • Input schemas reject unknown fields and bound string/array/nesting size
  • Response schemas are explicit — no bare ORM serialization
  • Function-level checks on admin/bulk endpoints, not just object-level
  • All caller-supplied URLs pass an egress allowlist; redirects re-validated
  • Cloud metadata endpoint unreachable from application containers
  • Errors return a correlation id, never SQL, stack traces, or hostnames
  • Route inventory generated from code and diffed against the spec in CI
  • Deprecated API versions have a sunset date and monitored residual traffic
  • Service-to-service calls are authenticated, not trusted by network position
  • Auth failures, authz denials, and 429s are logged with principal and route
  • Secrets come from a manager, and no credential appears in a log or URL