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
| # | Risk | Pack default | Manual config |
|---|---|---|---|
| API1 | Broken Object Level Authorization | needs_operator_input: adds object_authz with enumeration ready to go, blocking nothing until you add object_rules | Object access that trusts the caller's ID |
| API2 | Broken Authentication | not_covered: the provider choice is yours | Authentication that is weaker than it looks |
| API3 | Broken Object Property Level Authorization | needs_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 |
| API4 | Unrestricted Resource Consumption | needs_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 |
| API5 | Broken Function Level Authorization | needs_operator_input: shares API1's object_authz entry, blocking nothing until you add function_rules | Object access that trusts the caller's ID |
| API6 | Unrestricted Access to Sensitive Business Flows | not_covered: compose rate_limiting, object_authz, and bot checks yourself | Automated traffic you cannot distinguish |
| API7 | Server Side Request Forgery | enforced, always, with nothing synthesized: the SSRF guard already runs on every outbound dial sbproxy itself makes - not the backend's own server-side URL fetching | Requests the service makes on the caller's behalf |
| API8 | Security Misconfiguration | enforced: 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 coverage | Browser-facing misconfiguration |
| API9 | Improper Inventory Management | enforced: sets expose_openapi: true, a disclosure decision worth reviewing first | openapi-emission.md; unretired old versions are the classic finding here, so announce and retire them with a deprecation: block |
| API10 | Unsafe Consumption of APIs | not_covered: no response-handling safety net for third-party API calls today | n/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
- security.md for the whole picture across traffic types.
- owasp-api-top10.md for the
owasp_api_top10pack, item by item. - object-authz.md for object-level authorization in depth.
- audit-log.md for the audit record shapes.
- configuration.md for every field these examples use.