API security

September 9, 2026 ยท View on GitHub

Last modified: 2026-08-28

Most API breaches are not clever. They are an endpoint that forgot to check who was asking, a limit nobody set, or a field that was never supposed to be writable. A gateway is a good place to fix that class of problem, because it sits in front of every route whether or not the service behind it remembered.

This page covers the API threat classes sbproxy can act on, the configuration for each, and the parts that stay with the service. For MCP and agent traffic, see mcp-security.md. For the whole picture, start at security.md.

The public reference here is the OWASP API Security Top 10: owasp.org/API-Security. The sections below solve the same problems in configuration terms.

The owasp_api_top10 pack

Everything below can also be configured by hand, one policy at a time. The owasp_api_top10 pack is the faster path: one config entry that expands into the same policies and transforms, item by item, with an honest manifest naming exactly what it did for each.

    policies:
      - type: owasp_api_top10
        enable: all
#RiskPack defaultManual config
API1Broken Object Level Authorizationneeds_operator_input: adds object_authz with enumeration ready to go, blocking nothing until you add object_rulesObject access that trusts the caller's ID
API2Broken Authenticationnot_covered: the provider choice is yoursAuthentication that is weaker than it looks
API3Broken Object Property Level Authorizationneeds_operator_input by default; enforced once per_item.api3.response_exclude_fields is set (adds a json_projection transform, top-level object fields only, failure_posture: closed)Input the service will trust
API4Unrestricted Resource Consumptionneeds_operator_input by default (adds request_limit, concurrent_limit only); enforced once per_item.api4.rps is set (also adds rate_limiting, ddos_protection - confirm proxy.trusted_proxies first)No limit on what one caller can consume
API5Broken Function Level Authorizationneeds_operator_input: shares API1's object_authz entry, blocking nothing until you add function_rulesObject access that trusts the caller's ID
API6Unrestricted Access to Sensitive Business Flowsnot_covered: compose rate_limiting, object_authz, and bot checks yourselfAutomated traffic you cannot distinguish
API7Server Side Request Forgeryenforced, always, with nothing synthesized: the SSRF guard already runs on every outbound dial sbproxy itself makes - not the backend's own server-side URL fetchingRequests the service makes on the caller's behalf
API8Security Misconfigurationenforced: adds security_headers and http_framing on proxied and generated-response origins alike (static/mock/echo/beacon/redirect included; mcp/storage/ai_proxy/plugin actions only get http_framing); layer waf yourself for broader coverageBrowser-facing misconfiguration
API9Improper Inventory Managementenforced: sets expose_openapi: true, a disclosure decision worth reviewing firstopenapi-emission.md; unretired old versions are the classic finding here, so announce and retire them with a deprecation: block
API10Unsafe Consumption of APIsnot_covered: no response-handling safety net for third-party API calls todayn/a

enable: all defaults every item's posture to report_only. API7, API8, and API9 enforce regardless of posture: their controls either have no report-only mode or run outside the policy chain entirely. API4 also has no report-only mode, but is not "enforce regardless" the way those three are: its rate-shaped pieces (rate_limiting, ddos_protection) only synthesize once you supply per_item.api4.rps, because both key on caller IP by default and a blind default behind an unconfigured load balancer risks a real shared-budget outage - see owasp-api-top10.md for the trusted_proxies guidance before setting rps. See owasp-api-top10.md for what each item synthesizes, why, and what it still needs from you, and examples/owasp-api-top10/ and examples/owasp-api-selective/ for runnable configs.

Object access that trusts the caller's ID

The oldest and most common API flaw: GET /orders/1042 returns order 1042 to whoever asks, because the handler checked that you are logged in and not that the order is yours.

sbproxy enforces object-level authorization at the edge, so the check exists even when the handler forgot.

origins:
  "api.example.com":
    action:
      type: proxy
      url: "https://backend.internal"
    policies:
      - type: bola
        principal:
          owner_from: sub
        object_rules:
          - path: /tenants/{owner}/orders/{order_id}
            owner_param: owner
            object_param: order_id
        function_rules:
          - path: /admin/users/{user_id}
            methods: [DELETE, PUT]
            require_role: admin
        enumeration:
          enabled: true
          window_secs: 60
          max_distinct: 100

owner_from: sub reads the caller's identity from the verified auth subject rather than from anything the request supplied, which is the safe default. The enumeration block is the other half: it trips when one principal touches more distinct object ids than a real user would, which is what scraping looks like when every individual request is authorized. It does not need object_rules to work: with none declared at all, enumeration: { enabled: true } on its own catches a sweep against a bare /orders/{id}-shaped API, via a heuristic that requires an identified caller and never blocks on its own guess (see object-authz.md for exactly what that fallback covers and where it does not apply).

See object-authz.md for the full matcher surface, including tenant claims and collection endpoints, and examples/object-authz/ for a complete working config.

Still yours. The gateway compares an identifier in the request against a claim in the token. It cannot know that order 1042 belongs to user 7 unless that relationship is expressed somewhere it can see. For deep object graphs the service remains the authority.

Authentication that is weaker than it looks

Bearer tokens with no audience check, JWTs validated against the wrong issuer, a session cookie that survives logout. Each is ordinary and each is enough.

sbproxy ships auth providers rather than an auth framework, so the choice is which one to attach:

    auth:
      type: jwt
      jwks_url: "https://issuer.example/.well-known/jwks.json"
      issuer: "https://issuer.example"
      audience: "api.example.com"

oidc runs a full relying-party login with authorization code and PKCE and a sealed session cookie (auth-oidc.md). api_key and bearer_token cover machine callers. For machine callers that should prove possession of their secret on every request instead of sending it, hmac_auth verifies an RFC 9421 HMAC signature over the method, path, and a mandatory timestamp, so a captured request replays nowhere else and expires inside a configured window (configuration.md).

Two options on jwt are worth turning on if your issuer supports them. require_dpop: true demands an RFC 9449 proof whose jkt matches the token's cnf.jkt, so a stolen bearer is not enough on its own. require_mtls_bound: true requires the token's cnf.x5t#S256 to match the inbound client certificate (RFC 8705). Both fail closed when the binding metadata is absent, which is the behavior you want.

Every auth failure is recorded as a structured audit event naming the scheme that rejected it, and never the credential. The event_type says which shape the refusal took: auth_denied for a plain rejection, auth_denied_with_headers when the 401 also carries a challenge (basic_auth, cap, an OAuth resource-metadata pointer), auth_digest_challenge for the digest handshake, and forward_auth_denied when an external authorizer said no. Match on the auth_ prefix rather than one value; that is what the events bridge does when it turns any of them into one auth_denied typed event.

See examples/auth-jwt/ for a complete working config. authentication.md is the chooser across all twelve providers, including how one origin accepts more than one.

Still yours. Choosing an audience and issuer that actually narrow anything. A JWT validated against a wildcard audience is a validated JWT that proves little.

No limit on what one caller can consume

An endpoint with no rate limit is a denial-of-service primitive and a credential-stuffing oracle at the same time. It is also how a single retry loop takes down a backend at 3am.

    policies:
      - type: rate_limiting
        requests_per_minute: 600
      - type: concurrent_limiting
        max: 50
      - type: request_limiting
        max_body_size: 1048576
        max_header_count: 64
        max_header_size: 16384
        max_url_length: 2048
      - type: ddos_protection
        requests_per_second: 100
        block_duration_secs: 300

request_limiting is the one people skip and then regret, because it bounds the shapes that never reach a rate limiter: a 4 GB body, a header the parser chokes on, a URL long enough to be its own attack. agent_budget caps spend rather than requests, which is the limit that matters for AI-backed endpoints, and rate_limit_budget ties a limit to a budget rather than a fixed count.

Body size for a specific route is a payload_limit transform rather than a policy, which is worth knowing when you go looking for it.

Rate limit counters are shared across nodes when clustering is configured, so a limit means the same thing behind a load balancer instead of becoming per-instance. See configuration.md for the cluster fields.

See examples/rate-limiting/ for a complete working config.

Still yours. Picking numbers. A limit set above your actual capacity is documentation, not protection.

Input the service will trust

Injection, mass assignment, and schema drift are one problem wearing three names: the request contained something the service did not expect and handled anyway.

Validate against the contract you publish:

    policies:
      - type: openapi_validation
        spec_file: "./openapi.yaml"
        mode: enforce
        status: 400
      - type: request_validator
        schema:
          type: object
          required: [order_id]
          properties:
            order_id: { type: string }
      - type: waf

openapi_validation is the strongest of these, because it rejects anything your own specification does not describe, including fields an attacker hoped were silently bound. mode: log runs it in observation first, which is how you find out what your clients actually send before you start refusing. The spec goes inline or on disk; see openapi-validation.md and examples/openapi-validation/ for the full field set.

request_validator is the narrower tool when you want to check one field without publishing a whole spec; see examples/request-validator/ for a complete working config.

http_framing covers request smuggling and the framing tricks that let one request look like two, refusing conflicting Content-Length and Transfer-Encoding combinations rather than guessing which one the backend will believe.

Structural body threat limits

body_threat_protection bounds the shape of a JSON or XML request body rather than its content: how deep it nests, how many entries an object or items an array carries, how long keys and strings run, how many containers or elements the whole document holds. A parser-stressing payload is a shape problem before it is a content problem, and shape limits are immune to the encoding evasions a signature ruleset has to chase. Kong gates the equivalent capability (its JSON Threat Protection and XML Threat Protection plugins) behind its Enterprise tier; sbproxy ships it in OSS.

    policies:
      - type: body_threat_protection
        mode: block          # block (default) refuses with 400; tap logs + counts only
        json:
          max_depth: 64            # nesting depth; top-level container is 1
          max_object_entries: 10000 # entries in any single object
          max_array_items: 10000   # items in any single array
          max_key_length: 1024     # bytes per object key
          max_string_length: 131072 # bytes per string value
          max_containers: 50000    # objects + arrays in the whole document
        xml:
          max_depth: 64            # element nesting depth
          max_elements: 10000      # elements in the whole document
          max_attributes: 256      # attributes on any single element

Every field above shows its default; omitting the json: and xml: blocks enforces exactly these numbers. Setting a single limit to 0 disables that one check; setting enabled: false inside a block switches that family off entirely. One ceiling survives every override: JSON nesting deeper than 10,000 containers is always refused, because the scanner keeps a small state frame per open container and an unbounded depth would turn that bookkeeping into a memory amplifier. There is one non-configurable rule: an XML <!DOCTYPE declaration is always refused. Entity declarations live in the DTD, so refusing the DTD refuses the entire expansion class, billion laughs and external entities alike, without the proxy ever expanding anything.

The decision path per request:

flowchart TD
    REQ["Request arrives with a body"] --> CT{"Content-Type gate"}
    CT -->|"application/json, +json"| BUF["Buffer the body\n(replay-armed, 8 MiB hard cap)"]
    CT -->|"application/xml, text/xml, +xml"| BUF
    CT -->|"anything else, or absent"| PASS["Pass untouched:\nnot buffered, not scanned"]
    BUF --> SCAN["One-pass structural scan\n(JSON: iterative tokenizer,\nXML: pull reader, entities never expanded)"]
    SCAN --> DTD{"XML DOCTYPE?"}
    DTD -->|yes| V["Violation named:\nxml.doctype"]
    DTD -->|no| LIM{"Per-limit checks\n(depth, entries, items,\nkey/string length, containers,\nelements, attributes)"}
    LIM -->|"all within limits"| REL["Release the exact buffered\nbytes to the upstream"]
    LIM -->|"limit exceeded"| V2["Violation named:\njson.max_depth, xml.max_elements, ..."]
    V --> MODE{"mode"}
    V2 --> MODE
    MODE -->|block| B400["400 naming the violated limit,\nupstream never contacted,\npolicy counter action=deny,\nsecurity audit event"]
    MODE -->|tap| TAP["Log + policy counter action=tap"]
    TAP --> REL

The refusal names the limit and the observed and allowed numbers (json.max_depth: observed 65 exceeds the configured limit 64) and never echoes body content, the same rule request_validator follows. A body the scanner cannot finish (unterminated string, unbalanced brackets, malformed XML) is refused as json.malformed / xml.malformed: fail closed, because a body that defeats the guard's own parse should not get to try its luck upstream.

mode: tap exists for the same reason object_authz ships enumeration detection as detect-only: shape limits alone can false-positive on legitimately deep payloads, so operators can watch sbproxy_policy_triggers_total{policy_type="body_threat_protection",action="tap"} against real traffic before flipping to block.

Like request_validator and openapi_validation, the policy evaluates on the buffered request body, so it applies to actions that actually forward a body upstream (proxy, load_balancer, ai_proxy, and the other body-consuming actions). A static or mock origin answers during the request phase and never streams its request body, so bodies sent at those origins are not scanned; there is also nothing behind them for a hostile body to reach.

Interplay with the size caps: this policy deliberately has no body-size limit of its own. request_limit.max_body_size is the operator's byte cap, and the shared body-buffering seam this policy evaluates on refuses anything past its 8 MiB hard cap with a 413 before any scan runs, so an oversized body can never be used to balloon the proxy's memory on the way to a structural scan. A body too large to buffer is refused as too large; it is never waved through unscanned.

Scope, stated plainly: these are shape limits, not body inspection. The WAF's signature rules still do not read request bodies at all (waf-options.md), and body_threat_protection does not change that; it closes the structural slice of the gap, the slice that needs no rule engine. The signature-matching slice stays open, and the requirements a real CRS integration would have to meet are listed on that same page. The origin-level threat_protection: block is this policy's alpha-stability predecessor: JSON-only, 413s instead of naming the limit, no tap mode. Prefer the policy.

See examples/body-threat-protection/ for a runnable config with captured refusals for depth, entity expansion, and string length, plus the tap-mode run.

Still yours. Keeping the specification honest. Validation against a stale spec enforces last quarter's contract.

Requests the service makes on the caller's behalf

Server-side request forgery turns your API into a proxy for the attacker, and cloud metadata endpoints make it worth their while.

The SSRF guard refuses upstreams resolving to private address space by default:

proxy:
  extensions:
    upstream:
      allow_private_cidrs:
        - 10.0.0.0/8

That allowlist is the escape hatch, and it should stay short. Everything not listed is refused after DNS resolution, so a hostname that resolves to 169.254.169.254 does not become a credential leak.

Still yours. SSRF that happens entirely inside your service, without traversing the gateway, is invisible here.

Data leaving that should not

Two directions worth separating. Secrets leaking outward in responses, and regulated data leaving in ways you cannot account for.

    policies:
      - type: leaked_credentials
        action: block
        sha1_file: "./pwned-sha1.txt"
      - type: dlp
        detectors: [email, phone_us, credit_card, us_ssn]
        action: block

leaked_credentials catches the accidental case where a stack trace or debug field carries a key, matching against a list you supply as passwords, sha1_hashes, or a sha1_file.

dlp handles the regulated-data case, and it has two limits worth knowing before you plan around it.

It scans requests only: the URI and the headers. scan_body defaults true and body_max_bytes defaults 16384, but the header-phase policy chain snapshots an empty body, so a secret that appears only in the POST body is not seen. Setting direction: response or both is accepted and then warned about at load; the request-side scan still runs regardless. That is not a scheduling gap that a future release closes: dlp runs through the same request-only policy-enforcement phase every built-in policy shares, so scanning a response would need a different phase entirely, the one the response transforms already run in. So dlp catches regulated data on the way in, not on the way out.

Its actions are tag and block, not redact. tag marks the request for downstream handling and lets it through; block refuses it. Redact-and-continue exists on the AI path instead, in the guardrail mesh, where a pii guardrail can strip matches rather than refuse the request. See ai-gateway.md.

For data on the way out, the controls that actually run are leaked_credentials above and the response transforms. Where redaction does run, it runs before observability fan-out, so a redacted value does not reappear in a log or a trace.

Still yours. Classifying your own data. The detectors find shapes, not meaning.

Browser-facing misconfiguration

If your API is called from a browser, the boring headers are most of the work:

    policies:
      - type: security_headers
      - type: csrf
        secret_key: "${CSRF_SIGNING_KEY}"
        cookie_name: csrf_token
        safe_methods: [GET, HEAD, OPTIONS]
      - type: sri
        enforce: true
        algorithms: [sha384]

page_shield watches for third-party script drift on pages you serve. content_digest binds a body to its Content-Digest header so a proxy in between cannot alter it unnoticed.

See examples/csrf/ for a complete working config.

Still yours. CORS policy is a decision about who should be able to call you, and no default is right for everyone.

Automated traffic you cannot distinguish

Scrapers, credential stuffers, and AI crawlers all look like clients. Some you want, some you do not, and telling them apart by user agent stopped working years ago.

    policies:
      - type: ip_filtering
        blacklist: ["203.0.113.0/24"]
    auth:
      type: web_bot_auth

web_bot_auth verifies an RFC 9421 signature against a published key directory, which is the difference between a crawler claiming to be someone and one proving it. Its directory and key settings are in web-bot-auth.md. pay_per_crawl turns unwanted automation into a priced transaction rather than a block.

A request can carry evidence from several of these sources at once (Web Bot Auth, CAP, named-agent rule packs, TLS fingerprint signals), and each answers its own narrow question. request.trust_tier collapses that fan-out into one conservative verdict (suspicious, strong, named, or anonymous) so a policy asks one question instead of replicating every verifier's logic. See trust-tiers.md.

See examples/ip-filter/ for a complete working config of the ip_filtering policy above.

Still yours. Deciding which bots you want. The gateway will enforce either answer.

Not knowing an incident happened

Every denial above emits a structured security audit record with a stable event type and a closed reason label, so a SIEM rule can route on the failure mode without parsing prose. Records carry hostname, client IP, request id, method, status, and tenant when known, and never the offending header value, because attacker-controlled bytes in a SIEM log are their own problem.

Policy decisions are also counted:

sbproxy_policy_triggers_total{origin,policy_type,action}
sbproxy_auth_results_total

See audit-log.md for the record shapes and observability.md for the metric surface.

Still yours. Alerting. An audit stream nobody queries is storage.

A note on what a gateway cannot do

Everything above is enforcement at the edge. It composes badly with two things, and it is worth being direct about them.

Business-logic flaws are invisible here. A gateway can confirm you are allowed to call POST /transfer, not that transferring this amount to this account makes sense.

And a control at the edge is only as good as the edge being unavoidable. If a service is reachable directly, every policy on this page is optional from the attacker's point of view. Network placement is the precondition for all of it.

Where to go next