SBproxy Configuration Reference

July 14, 2026 ยท View on GitHub

Last modified: 2026-07-13

The complete configuration reference for SBproxy. Every option, every field, every action type is documented here with real-world examples you can copy-paste and run.

For AI-specific features in depth, see ai-gateway.md. For CEL, Lua, JavaScript, and WASM scripting, see scripting.md. For the event system, see events.md.

Table of contents

  1. Overview
  2. Top-level structure
  3. Proxy settings
  4. Origins
  5. Actions
  6. Authentication
  7. Policies
  8. Transforms
  9. Request modifiers
  10. Response modifiers
  11. Response cache
  12. Forward rules
  13. Fallback origin
  14. Variables, vaults, and secrets
  15. Session config
  16. Compression
  17. HSTS
  18. Connection pool
  19. Bot detection
  20. Threat protection
  21. Error pages
  22. Rate limit headers
  23. Message signatures
  24. Traffic capture
  25. Host header semantics
  26. Trusted proxies and forwarding headers
  27. Request mirror
  28. Upstream retries
  29. Active health checks
  30. Circuit breaker
  31. Outlier detection
  32. Service discovery
  33. Correlation ID
  34. mTLS client authentication
  35. Webhook envelope and signing
  36. Secrets
  37. Environment variables
  38. ACME / auto TLS
  39. Redis integration
  40. Validation

Overview

SBproxy reads its configuration from a YAML file, typically named sb.yml. This file defines how the proxy listens for traffic, which hostnames it handles, and what it does with each request.

Load a config file. The path must be supplied explicitly; the binary does not auto-discover sb.yml in the current directory.

# Explicit path
sbproxy --config /etc/sbproxy/production.yml

# Same thing via the `serve` subcommand and the short flag
sbproxy serve -f /etc/sbproxy/production.yml

# Or via env var for containerised deployments
SB_CONFIG_FILE=/etc/sbproxy/production.yml sbproxy

Validate without starting:

sbproxy validate /etc/sbproxy/production.yml
# or
sbproxy --config /etc/sbproxy/production.yml --check

The config has two main sections: proxy (server-level settings) and origins (per-hostname routing and behavior). Optional shared-state blocks (l2_cache_settings, messenger_settings) live nested under proxy.


JSON Schema (editor autocomplete + validation)

SBproxy ships a JSON Schema at schemas/sb-config.schema.json. Editor tooling that understands the yaml-language-server directive (VS Code with the YAML extension, IntelliJ / JetBrains, Helix) reads this schema and validates sb.yml field names + types in real time. A typo in a key surfaces as an editor error rather than as a runtime parse failure.

Opt in by adding a comment header at the top of your sb.yml:

# yaml-language-server: $schema=https://raw.githubusercontent.com/soapbucket/sbproxy/main/schemas/sb-config.schema.json
proxy:
  http_bind_port: 8080
origins:
  "api.example.com":
    action: { type: proxy, url: http://127.0.0.1:9000 }

Every examples/*/sb.yml in this repo carries the header pointing at the local schemas/ path so the examples are self-validating against the same schema operators consume.

The schema is generated from the Rust types in crates/sbproxy-config/src/types.rs so it cannot drift from the runtime. Regenerate locally with:

cargo run -p sbproxy-config --bin generate-schema > schemas/sb-config.schema.json

The CI gate scripts/check-config-schema.sh runs the generator and diffs against the committed file; a Rust type change that does not regenerate the schema is rejected at PR time. The generator is deterministic (the preserve_order feature on schemars keeps object property order stable), so the diff is byte-for-byte.


Top-level structure

Complete YAML skeleton with every top-level key:

# Server settings (ports, TLS, ACME, admin, secrets, shared state)
proxy:
  http_bind_port: 8080
  https_bind_port: 8443
  tls_cert_file: /etc/sbproxy/cert.pem
  tls_key_file: /etc/sbproxy/key.pem
  acme: { ... }
  http3: { ... }
  metrics: { ... }
  alerting: { ... }
  admin: { ... }
  secrets: { ... }
  cluster: { ... }
  model_host: { ... }

  # L2 cache (Redis) for distributed rate limiting and caching
  l2_cache_settings:
    driver: redis
    params:
      dsn: redis://localhost:6379/0

  # Messenger (Redis) for real-time config updates
  messenger_settings:
    driver: redis
    params:
      dsn: redis://localhost:6379

  # Opaque per-server extensions consumed by enterprise / third-party crates.
  extensions: { ... }

# Agent classification catalog and resolver tuning
agent_classes:
  catalog: builtin
  resolver:
    rdns_enabled: true
    bot_auth_keyid_enabled: true
    cache_size: 10000

# Per-hostname origin configurations
origins:
  "api.example.com":
    action: { ... }
    authentication: { ... }
    policies: [ ... ]
    transforms: [ ... ]
    request_modifiers: [ ... ]
    response_modifiers: [ ... ]
    forward_rules: [ ... ]
    response_cache: { ... }
    variables: { ... }
    session: { ... }
    cors: { ... }
    compression: { ... }
    hsts: { ... }
    connection_pool: { ... }
    extensions: { ... }

l2_cache_settings and messenger_settings are nested under proxy: (the deserializer also accepts l2_cache as a canonical alias).

Agent classes

The optional top-level agent_classes block configures the process-wide agent identity resolver. Omitting it uses the embedded catalog and default resolver tuning.

agent_classes:
  catalog: inline
  entries:
    - id: openai-gptbot
      vendor: OpenAI
      purpose: training
      expected_user_agent_pattern: "(?i)\\bGPTBot/\\d"
      expected_reverse_dns_suffixes: [".gptbot.openai.com"]
      expected_keyids: ["openai-2026-01"]
  resolver:
    rdns_enabled: true
    bot_auth_keyid_enabled: true
    cache_size: 10000
FieldTypeDefaultDescription
catalogstringbuiltinbuiltin loads the embedded catalog. inline loads entries. hosted-feed and merged are reserved for the registry fetcher and currently fall back to builtin.
entrieslist[]Complete inline catalog used when catalog: inline. Entries use id, vendor, purpose, expected_user_agent_pattern, optional expected_reverse_dns_suffixes, and optional expected_keyids.
resolver.rdns_enabledbooltrueRun forward-confirmed reverse DNS as resolver step 2.
resolver.bot_auth_keyid_enabledbooltrueLet a verified Web Bot Auth keyid match expected_keyids as resolver step 1.
resolver.cache_sizeint10000Per-process reverse-DNS verdict cache capacity.

Proxy settings

The proxy block configures server-level behavior: ports, TLS, ACME, the admin API, metrics, secrets, and the optional shared-state backends.

proxy:
  http_bind_port: 8080
  https_bind_port: 8443
  tls_cert_file: /etc/sbproxy/cert.pem
  tls_key_file: /etc/sbproxy/key.pem

  acme:
    enabled: true
    email: admin@example.com
    storage_path: /var/lib/sbproxy/certs

  http3:
    enabled: false

  metrics:
    max_cardinality_per_label: 1000
    cardinality:
      hostname_cap: 200

  admin:
    enabled: false
    port: 9090

Proxy fields

FieldTypeDefaultDescription
http_bind_portint8080HTTP listen port
https_bind_portintunsetOptional HTTPS listen port. Requires tls_cert_file + tls_key_file or an acme block.
tls_cert_filestringPath to PEM-encoded TLS certificate. Ignored when acme is configured.
tls_key_filestringPath to PEM-encoded TLS private key.
acmeobjectACME (auto-TLS) block. Overrides manual cert/key when set. See ACME / auto TLS.
http3objectHTTP/3 (QUIC) listener config. Currently inert; see HTTP/3 fields.
metricsobjectMetrics tuning, including label cardinality limits.
alertingobjectAlert notification channels.
adminobjectEmbedded read-only admin / stats API server.
secretsobjectSecrets management backend. See Secrets.
clusterobjectunsetCanonical local or distributed cluster identity, membership, mTLS, enrollment, snapshot, and signed deployment-authority settings.
model_hostobjectunsetCanonical managed-model authority, cache, engines, deployments, placement, and rollout policy.
l2_cache_settingsobjectOptional shared-state backend. Alias: l2_cache.
messenger_settingsobjectOptional shared message bus for inter-component eventing.
trusted_proxiesarray of CIDR strings[]Source ranges whose inbound X-Forwarded-For / X-Real-IP / Forwarded headers are honoured. Connections from outside the list have those headers stripped on ingress so they cannot spoof identity. IPv6 CIDRs work. See Trusted proxies and forwarding headers.
correlation_idobjectenabled, X-Request-Id, echo onCorrelation-ID propagation policy. See Correlation ID.
mtlsobjectunsetmTLS client-certificate verification on the HTTPS listener. See mTLS client authentication.
http_client_timeoutsobject(see below)Tunable timeouts for the proxy's outbound HTTP helpers (forward-auth, callbacks, mirrors, SWR refreshes, bot-auth directory). See HTTP client timeouts.
extensionsobjectOpaque map for enterprise / third-party top-level config blocks. OSS never parses these.

HTTP client timeouts

The proxy keeps a small set of pooled reqwest::Client instances for its outbound helper requests. Each one used to bake a hardcoded timeout into the binary; operators who wanted a slower forward-auth deadline or a shorter callback budget had to fork the binary. The http_client_timeouts block exposes those numbers as config keys.

All fields default to the values the binary used before this block existed, so omitting it leaves behaviour unchanged.

proxy:
  http_client_timeouts:
    forward_auth_client_secs: 30
    forward_auth_request_secs: 5
    bot_auth_directory_client_secs: 5
    swr_client_secs: 30
    callback_client_secs: 10
FieldTypeDefaultDescription
forward_auth_client_secsint30Outer client-level timeout for the shared forward-auth client. The per-provider forward_auth.timeout field still applies on top.
forward_auth_request_secsint5Per-request fallback timeout for a forward-auth subrequest when the provider's own timeout field is unset.
bot_auth_directory_client_secsint5Client-level timeout for the Web Bot Auth directory lookup client.
swr_client_secsint30Client-level timeout for the stale-while-revalidate background refresh client.
callback_client_secsint10Client-level timeout for the callback / webhook client used by fire-and-forget POSTs.

HTTP/3 fields

HTTP/3 is temporarily disabled until native QUIC support lands in Pingora. The http3 block still parses, but no QUIC listener starts and setting enabled: true only logs a warning. The fields below are documented for forward compatibility; they have no runtime effect today.

FieldTypeDefaultDescription
enabledboolfalseEnable the HTTP/3 (QUIC) listener. Currently inert; no listener starts.
max_streamsint100Maximum concurrent QUIC streams per connection. Currently inert.
idle_timeout_secsint30Idle timeout for QUIC connections. Currently inert.

Admin fields

FieldTypeDefaultDescription
enabledboolfalseEnable the admin server
portint9090Listen port
usernamestring"admin"Top-level admin HTTP Basic username
passwordstring"changeme"Top-level admin HTTP Basic password
max_log_entriesint1000Recent-request log buffer size
bindstring"127.0.0.1"Bind address; set to 0.0.0.0 or an interface for remote admin
allow_ipslistemptyIP / CIDR allowlist; empty keeps the loopback-only default
cors_originslistemptyAllowed CORS origins for a separately hosted UI
operatorslistemptyLogin identities with roles: {username, password, role} where role is admin or read_only
tlsobjectunset{cert, key} PEM paths; serve HTTPS instead of plaintext
prompt_persistence_pathstringunsetredb file persisting prompt-version edits across restarts

When enabled, the admin server binds bind:<port> (loopback by default), authenticates every request (HTTP Basic or a browser session), enforces the operator's role on mutations, and applies a rate limit of 60 requests per minute per client IP, with a global cap of 600 requests per minute across all clients. Full auth, RBAC, remote-access, and endpoint reference is in admin.md. Endpoints (abbreviated):

PathDescription
GET /api/healthLiveness check returning {"status":"ok"}.
GET /api/openapi.jsonEmitted OpenAPI 3.0 document for the running pipeline.
GET /api/openapi.yamlSame document in YAML.
POST /admin/reloadRe-read the on-disk config file and hot-swap the pipeline. Single-flight; concurrent calls return 409.
GET /admin/driftCompare the on-disk config file against the loaded baseline. See below.

Unauthenticated requests get a 401 with a WWW-Authenticate: Basic header. Requests from outside 127.0.0.1 are dropped at the socket level.

GET /admin/drift

Returns whether the on-disk config file has diverged from what the running proxy has loaded, without triggering a reload. K8s operators and dashboards scrape this so they can flag a config that was edited on disk but not yet hot-reloaded.

Response shape (200 OK):

{
  "config_path": "/etc/sbproxy/sb.yml",
  "loaded_revision": "a3f5b1d829c4",
  "loaded_content_hash": "8e1c5d4a9f7b",
  "on_disk_content_hash": "8e1c5d4a9f7b",
  "drift": false,
  "on_disk_size_bytes": 4321,
  "checked_at": "2026-05-06T15:42:00Z"
}
  • loaded_revision is the 12-char origin-set identity hash from the running pipeline. Stable when only policies, transforms, or ports change; moves when origins or hostnames are added or removed.
  • loaded_content_hash is the 12-char SHA-256 prefix of the raw YAML bytes captured at load time (startup or last successful /admin/reload).
  • on_disk_content_hash is the same hash recomputed against the current file contents.
  • drift is true iff the two content hashes differ.

Failure modes:

  • 503 - the admin server has no on-disk config path (constructed without with_config_path, e.g. tests), or no content-hash baseline has been captured yet (no startup load and no successful reload).
  • 500 - the on-disk file could not be read. The error message has the absolute path scrubbed so the response does not leak the operator's filesystem layout.
  • 405 - any verb other than GET.

Cluster fields

proxy.cluster creates one process-owned cluster handle used by model control and the mesh key cache. Production mode requires mTLS plus an authenticated gossip key:

proxy:
  cluster:
    cluster_id: production-models
    node_id: worker-a
    roles: [worker]
    labels: {zone: us-central1-a}
    seeds: [10.10.0.10:7946]
    gossip_port: 7946
    transport_port: 8946
    advertise_addr: 10.10.0.21:7946
    transport_advertise_addr: 10.10.0.21:8946
    model_bind: 0.0.0.0:9443
    model_endpoint: https://10.10.0.21:9443
    state_dir: /var/lib/sbproxy/cluster
    snapshot_ttl_secs: 30
    publish_interval_secs: 5
    dead_peer_gc_secs: 300
    security:
      mode: mtls
      shared_key: file:/var/lib/sbproxy/cluster/gossip.key
      cert_file: /var/lib/sbproxy/cluster/node.pem
      key_file: /var/lib/sbproxy/cluster/node-key.pem
      ca_file: /var/lib/sbproxy/cluster/ca.pem
      server_name: sbproxy-mesh
FieldTypeDefaultDescription
cluster_idstringrequiredStable logical cluster identity shared by every member.
node_idstringrequiredStable unique node identity.
roleslistrequiredAny combination of gateway, worker, and authority.
labelsmap{}Bounded authenticated placement and failure-domain labels.
seedslist[]Static UDP gossip seed addresses in host:port form.
gossip_portint7946UDP SWIM listener.
transport_portint8946TCP typed-state and cache transport listener.
advertise_addrstringobserved addressGossip address advertised to peers. Enrolled mTLS startup requires an explicit routable IP and port.
transport_advertise_addrstringadvertised host plus transport portmTLS typed-state address advertised to peers.
model_bindIP:portunsetDedicated private HTTP/2 model-plane listener. Worker role only; requires model_endpoint.
model_endpointabsolute HTTP URLunsetPrivate model-plane origin advertised by workers. Production mTLS requires https://; explicit shared-key development requires http://. Required for peer placement eligibility.
state_dirpathrequiredInstalled identity plus durable boot, peer-identity high-water, snapshot generation, deployment generation, and authority cursor state.
snapshot_ttl_secsint30Worker snapshot lifetime; at least two publish intervals.
publish_interval_secsint5Snapshot publication cadence.
dead_peer_gc_secsint300Seconds before SWIM removes a dead peer from routing membership. The admin roster retains a bounded tombstone after this GC.
security.modeenumrequiredmtls for production or shared_key for explicit development.
security.developmentboolfalseMust be true for shared-key-only mode.
security.shared_keysecret referenceunsetUDP gossip key; required in mTLS production mode too.
security.cert_file, key_file, ca_filepathsunsetPer-node mTLS material.
security.server_namestringsbproxy-meshCluster SAN bound into enrollment. Canonical outbound transport additionally verifies the target node_id SAN.
enrollment.authority_dirpathunsetIdentity directory used by an authority-role process to enroll nodes.
deployment_authority.verifying_key_filepathunsetEd25519 public key installed on every node.
deployment_authority.signing_key_filepathunsetAuthority-only Ed25519 private key.

Canonical mTLS supports built-in enrollment or operator-managed PKI. Enrollment startup verifies state_dir/identity.json with state_dir/authority-verifying.key. Manual PKI omits both files and requires the leaf certificate to contain the node ID DNS SAN plus exactly one SBproxy identity URI SAN with cluster ID, node ID, roles, labels, server name, and a positive identity epoch. Every claim must match config. Do not mix attestation modes within a cluster, and increment the manual identity epoch for certificate rotation. state_dir also stores an atomically advanced boot epoch used to reject join replay after restart. Each verifier durably retains the highest accepted identity, certificate, and boot epoch per peer. Model controllers persist per-deployment generation high-water marks before publishing a placement commit, so an unplaced or fully drained deployment cannot reset after restart. Identity, roles, labels, discovery, listeners, advertised endpoints, security, state, dead-peer GC, and authority changes require restart. Snapshot cadence reloads in place. The model plane accepts only its internal versioned dispatch path. Production uses mTLS with HTTP/2 ALPN and peer-identity proofs; explicit development mode uses h2c plus HMAC. model_bind is never an engine port and should be reachable only from cluster gateways. See model-host.md for enrollment, placement, signed bundle, and admin status workflows.

Model-host fields

proxy.model_host is the canonical managed-model desired state:

proxy:
  model_host:
    authority: file_managed
    catalog_file: /etc/sbproxy/models.yaml
    max_parallel_prepares: 2
    safety_margin: 0.10
    shutdown_deadline_ms: 30000
    handoff_timeout_ms: 60000
    cache:
      directory: /var/lib/sbproxy/models
      budget_gib: 100
      max_resident_models: 2
    deployments:
      local-qwen:
        model: qwen2.5-0.5b-instruct
        variant: q4_k_m
        replicas: 2
        required_labels: {accelerator: l4}
        spread_by: [zone]
        pull: on_boot
        warm: true
        cold_start: fallback
        engine: llama_cpp
        rollout: rolling

Authority values are file_managed, admin_managed, and cluster_authority. Deployment pull values are on_boot, on_demand, and manual; cold-start values are wait, reject, and fallback; rollout values are rolling and recreate. wait coordinates a bounded launch per selected replica generation, reject returns a retryable 503 with Retry-After: 1, and fallback advances to the next provider without launching. For authority: file_managed, omission follows the security profile: production mTLS clusters use fallback, while development and non-clustered runtimes use wait. Admin-managed and cluster-authority deployments must set cold_start explicitly. Replicated homogeneous deployments must pin a variant unless heterogeneous_variants: true is set. catalog_file selects a catalog v2 document and resolves relative paths from the directory containing sb.yml; omission uses the built-in catalog. A canonical catalog_file takes precedence over compatibility provider serve.catalog_file declarations. required_labels filters workers and spread_by orders failure domains. handoff_timeout_ms bounds how long rolling placement retains losing assignments while target readiness converges. See model-host.md for the engine, cache, admission, placement, and rollout contracts.

Metrics fields

FieldTypeDefaultDescription
max_cardinality_per_labelint1000Default cap on unique label values per metric. New values are collapsed to __other__.
cardinality.hostname_capint200Optional override for the hostname label budget. Useful for high-tenant-count deployments and deterministic overflow tests.

access_log

Top-level block (sibling of proxy: and origins:) that turns on structured-JSON access logging. Off by default. When enabled, every completed request emits one JSON line at info level via the access_log tracing target after status, method, and sampling filters apply. Secrets are redacted before the line is written. See Access log for the full record shape.

access_log:
  enabled: true
  sample_rate: 1.0
  status_codes: []           # empty = log every status
  methods: []                # empty = log every method
FieldTypeDefaultDescription
enabledboolfalseMaster switch. When false, no access-log lines are emitted.
sample_ratefloat1.0Probability in [0.0, 1.0] that a matching request is logged.
status_codeslist[]HTTP status codes to log. Empty matches every status.
methodslist[]HTTP methods to log (case-insensitive). Empty matches every method.

Alerting fields

The proxy.alerting block defines notification channels that receive alert events from the runtime.

proxy:
  alerting:
    channels:
      - type: webhook
        url: https://hooks.example.com/sbproxy
        headers:
          X-Auth: ${ALERT_TOKEN}
      - type: log
FieldTypeDefaultDescription
channelslist[]Notification channels.
channels[].typestringrequiredChannel type. Supported: webhook, log.
channels[].urlstringWebhook URL. Required when type is webhook.
channels[].headersmap{}Extra HTTP headers added to webhook deliveries.

An alert channel accepts exactly these three keys. A secret: key on a channel is rejected at config load as an unknown key; alert-webhook payload signing is not configurable yet. To sign webhook deliveries, use the secret field on per-origin on_request / on_response callbacks instead. See Webhook envelope and signing.

Alert webhook deliveries also include the standard X-Sbproxy-* identity headers (Event, Instance, Rule, Severity, Timestamp) and a User-Agent: sbproxy/<version>. The body is wrapped in an envelope:

{
  "event": "alert",
  "proxy": { "instance_id": "...", "version": "..." },
  "alert": { "rule": "...", "severity": "...", "message": "...", "timestamp": "...", "labels": { ... } }
}

l2_cache_settings

The l2_cache_settings block points the proxy at a shared key-value backend used for cluster-wide rate limit counters and (optionally) response cache entries. When unset, every replica keeps its own in-memory state. The deserializer also accepts l2_cache: as an alias.

The driver field selects the backend; params is a flat string map whose keys depend on the driver. Only the redis driver is implemented in the Rust proxy today.

proxy:
  l2_cache_settings:
    driver: redis
    params:
      dsn: redis://redis.internal:6379/0

params keys for the redis driver:

KeyTypeDefaultDescription
dsnstringConnection string. Accepts redis://[user[:pass]@]host:port[/db], rediss://..., or a bare host:port. The database index in the path is parsed but ignored by the single-connection RESP client.

Pool size and acquire timeout are not exposed via params and use built-in defaults (pool size 8, acquire timeout 5 seconds).

messenger_settings

The messenger_settings block configures the message bus the proxy uses for inter-component events such as config updates and semantic-cache purges. When unset, the proxy runs without a bus, which is fine for single-replica deployments.

The driver field picks the implementation; params is a flat string map whose keys depend on the driver. Unknown driver names cause startup to error.

proxy:
  messenger_settings:
    driver: redis
    params:
      dsn: redis://redis.internal:6379

Supported drivers and their params keys:

memory takes no params. It uses bounded in-process channels and only works for a single replica.

redis:

KeyTypeDefaultDescription
dsnstringredis://127.0.0.1:6379Redis connection string. Same parsing rules as the L2 cache dsn.

sqs (all required):

KeyTypeDescription
queue_urlstringFull SQS queue URL.
regionstringAWS region the queue lives in.
api_keystringAWS access key used to sign requests.

gcp_pubsub (all required):

KeyTypeDescription
projectstringGCP project ID that owns the topic.
topicstringPub/Sub topic name.
subscriptionstringPub/Sub subscription name.
access_tokenstringOAuth2 access token used on requests.

Tenants

SBproxy is a multi-tenant gateway. A tenant scope groups an operator's tenant of record (a customer, a deployment slice, a regulatory boundary) so the same proxy binary can serve isolated configurations. Every origin resolves to exactly one tenant; downstream auth, policy, and vault resolution picks the tenant-scoped config block before falling back to proxy-level defaults.

For single-tenant deployments the synthetic __default__ tenant is used implicitly; no operator action is required and existing configs see no behaviour change.

proxy:
  tenants:
    - id: acme-corp
    - id: beta-corp

origins:
  api.acme.example.com:
    tenant_id: acme-corp
    action:
      type: ai_proxy
      url: https://api.openai.com
  api.beta.example.com:
    tenant_id: beta-corp
    action:
      type: ai_proxy
      url: https://api.anthropic.com

Field schema

FieldTypeDefaultDescription
proxy.tenants[].idstringrequiredStable identifier. Referenced from origin.tenant_id and stamped on every request the origin serves. Max 256 ASCII characters. The literal __default__ is reserved and cannot be declared.

Resolution rules

  • A request matches an origin by hostname. The origin's tenant_id (or __default__) becomes RequestContext.tenant_id for the rest of the request lifecycle.
  • An origin that names an undeclared tenant fails config compile so an operator's typo surfaces at startup rather than at request time.
  • An empty proxy.tenants: list is the same as omitting it; every origin resolves to __default__.

Credentials at the tenant scope

Each tenant can declare its own credentials: block alongside the proxy default. Resolution at request time walks origin โ†’ tenant โ†’ proxy. The same credential name: re-declared at a more specific scope shadows the broader scope, so a tenant can override the proxy default key + budget without rewriting the rest. See migration-credentials.md for the worked migration from the legacy virtual_keys: shape.


Origins

Each key under origins is a hostname. When a request arrives, SBproxy matches the Host header to an origin key and applies that origin's configuration. Every origin must have an action block.

origins:
  "api.example.com":
    force_ssl: true
    allowed_methods: [GET, POST, PUT, DELETE]
    action:
      type: proxy
      url: https://backend.internal:8080

Hostname matching

  • Exact match: "api.example.com" matches only api.example.com.
  • Wildcard match: "*.example.com" matches api.example.com, www.example.com, and so on. The wildcard must be the first character and only covers one subdomain level.
  • Multiple origins: define as many as you need. Each has independent auth, policies, and routing.

Origin fields

FieldTypeDefaultDescription
actionobjectrequiredWhat to do with the request (proxy, redirect, static, etc.).
tenant_idstring__default__Tenant this origin resolves to. Must match a proxy.tenants[].id; absent uses the synthetic __default__ tenant. Stamped on the request context for auth / policy / vault resolution. See Tenants.
authenticationobjectAuth provider. Alias: auth.
policieslistPolicy enforcers (rate limit, IP filter, WAF, etc.).
transformslistBody transforms applied in order.
request_modifierslistHeader / URL / query / body / script edits before the action.
response_modifierslistHeader / status / body / script edits after the action.
corsobjectCORS header injection.
hstsobjectHSTS header injection.
compressionobjectResponse compression.
sessionobjectSession cookie settings. Alias: session_config.
force_sslboolfalseRedirect plain HTTP requests to HTTPS.
allowed_methodslistempty (allow all)Whitelist of HTTP methods.
forward_ruleslistPath / header / IP rules that route to inline child origins.
fallback_originobjectInline origin served when the primary upstream errors or returns a configured status. See Fallback origin.
response_cacheobjectPer-origin response cache.
variablesmapStatic template variables.
on_requestlistWebhook callbacks invoked when a request enters the origin. Each entry accepts url, method (default POST), secret (HMAC), timeout (seconds), on_error. Lua callbacks are also accepted. See Webhook envelope and signing.
on_responselistSame shape as on_request; fired after the upstream response is observed. Payload includes status and duration_ms.
mirrorobjectShadow traffic configuration. See Request mirror.
bot_detectionobjectBot detection config.
threat_protectionobjectIP reputation / blocklist config.
rate_limit_headersobjectX-RateLimit-* and Retry-After header configuration.
error_pageslistCustom error pages keyed by status code or class.
problem_detailsobjectRFC 9457 application/problem+json default renderer. Composes with error_pages.
traffic_captureobjectTraffic capture / mirroring.
message_signaturesobjectRFC 9421 HTTP message signatures.
idempotencyobjectRFC 8594 idempotency middleware. See Idempotency.
connection_poolobjectPer-origin connection pool tuning.
extensionsobjectOpaque map for enterprise / third-party origin-level blocks.

Origin architecture

Every origin config block supports the fields above as siblings. They sit at the same level as action, never inside it:

origins:
  "api.example.com":
    action: { ... }              # Required
    authentication: { ... }      # Optional
    policies: [ ... ]            # Optional
    transforms: [ ... ]          # Optional
    request_modifiers: [ ... ]   # Optional
    response_modifiers: [ ... ]  # Optional
    forward_rules: [ ... ]       # Optional
    response_cache: { ... }      # Optional
    variables: { ... }           # Optional
    session: { ... }             # Optional
    cors: { ... }                # Optional
    compression: { ... }         # Optional
    hsts: { ... }                # Optional
    connection_pool: { ... }     # Optional

Actions

The action block defines what the proxy does with a matched request. The type field selects the handler.

proxy

Forward requests to an upstream URL. The most common action type, and the right choice when SBproxy sits in front of an existing backend.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
      strip_base_path: false
      preserve_query: true
FieldTypeDefaultDescription
urlstringrequiredUpstream URL to forward requests to
strip_base_pathboolfalseStrip the matched origin path before forwarding
preserve_queryboolfalseForward the original query string to the upstream
host_overridestringunsetOverride the upstream Host header. Default is the upstream URL's hostname (so vhost-routed services like Vercel, Cloudflare-fronted origins, S3, ALBs work without configuration). See Host header semantics.
sni_overridestringunsetOverride the SNI server name sent during the upstream TLS handshake (and the cert verification target). Use when the cert's hostname differs from the URL host. See Origin overrides.
resolve_overridestringunsetPin the upstream connect address, bypassing DNS for the URL host. Accepts ip, ip:port, [ipv6]:port, or host:port. Equivalent to curl --connect-to. See Origin overrides.
service_discoveryobjectunsetDNS-based service discovery. Re-resolves the upstream hostname on a TTL. See Service discovery.
disable_forwarded_host_headerboolfalseSuppress the X-Forwarded-Host header that the proxy would otherwise set to the client's original Host whenever it rewrites the upstream Host.
disable_forwarded_for_headerboolfalseSuppress X-Forwarded-For (the client IP appended to the chain).
disable_real_ip_headerboolfalseSuppress X-Real-IP.
disable_forwarded_proto_headerboolfalseSuppress X-Forwarded-Proto (http/https).
disable_forwarded_port_headerboolfalseSuppress X-Forwarded-Port (the listener port).
disable_forwarded_headerboolfalseSuppress the RFC 7239 Forwarded header.
disable_via_headerboolfalseSuppress the Via: 1.1 sbproxy header.
retryobjectunsetUpstream retry policy. See Upstream retries.

The same host_override and disable_*_header flags are accepted on every URL-bearing action: proxy, load_balancer targets, websocket, grpc (via the :authority field), graphql, a2a, and forward_auth.

static

A static action answering with fixed body and headers next to a mock action templating the request back

(config)

Return a fixed response without proxying to any upstream. Good for health check endpoints, maintenance pages, and mock APIs.

origins:
  "status.example.com":
    action:
      type: static
      status: 200
      content_type: application/json
      json_body:
        status: healthy
        version: "2.1.0"
        services:
          database: up
          cache: up
FieldTypeDefaultDescription
statusint200HTTP status code (alias: status_code)
content_typestringContent-Type header
bodystringPlain text or HTML body (alias: text_body)
json_bodyobjectJSON body. Auto-sets Content-Type to application/json. Overrides body.
headersmapAdditional response headers

redirect

Return an HTTP redirect. Common uses: domain migrations, HTTPS enforcement, URL shortening, large URL lookup tables.

origins:
  "old.example.com":
    action:
      type: redirect
      url: https://new.example.com
      status: 302
      preserve_query: true
FieldTypeDefaultDescription
urlstringrequired*Redirect target URL. Required when bulk_list is unset.
statusint302HTTP status code (alias: status_code).
preserve_queryboolfalsePreserve original query string.
bulk_listobjectunsetPer-origin bulk redirect source. See bulk-redirects.md.

bulk_list accepts three source types: inline (rows embedded in YAML), file (CSV or YAML on disk; CSV detected by .csv suffix), and url (HTTPS document fetched at config-load). Per-row status and preserve_query overrides win when set; otherwise rows inherit the action's defaults. Unmapped paths fall through to the action's url: (or 404 when url: is empty).

origins:
  "marketing.local":
    action:
      type: redirect
      status_code: 301
      preserve_query: true
      bulk_list:
        type: file
        path: /etc/sbproxy/marketing-redirects.csv

echo

Return the incoming request as a JSON response. Handy for debugging proxy behavior, testing forward rules, and verifying that headers and auth are set up correctly. Echo takes no fields.

origins:
  "debug.example.com":
    action:
      type: echo

mock

Return a fixed JSON response for API mocking. Optionally injects an artificial delay so you can test slow-backend behavior.

origins:
  "mock.example.com":
    action:
      type: mock
      status: 200
      body:
        ok: true
        message: "mocked"
      headers:
        X-Mock: "true"
      delay_ms: 250
FieldTypeDefaultDescription
statusint200HTTP status code
bodyobjectnullJSON body returned to the client
headersmapAdditional response headers
delay_msintOptional artificial delay in milliseconds

beacon

Return a 1x1 transparent GIF. Useful for tracking pixel endpoints. Beacon takes no fields.

origins:
  "px.example.com":
    action:
      type: beacon

load_balancer

Distribute traffic across multiple backend targets when you have several instances of a service.

origins:
  "api.example.com":
    action:
      type: load_balancer
      algorithm: round_robin
      targets:
        - url: https://backend-1.internal:8080
          weight: 70
        - url: https://backend-2.internal:8080
          weight: 30
      sticky:
        cookie_name: sb_sticky
        ttl: 3600
FieldTypeDefaultDescription
targetslistrequiredBackend targets.
algorithmstring | objectround_robinRouting algorithm (see below).
stickyobjectSticky-session config: cookie_name (default sb_sticky), ttl seconds.
deployment_modeobject{mode: normal}Deployment mode. See below.
outlier_detectionobjectunsetPassive ejection policy. See Outlier detection.

Algorithms:

AlgorithmDescription
round_robinCycle through active targets in order (default).
weighted_randomPick a target with probability proportional to its weight.
least_connectionsRoute to the target with the fewest in-flight requests.
ip_hashHash the client IP to a target (sticky by client).
uri_hashHash the request URI to a target (sticky by path).
header_hashHash a named request header. Configured as algorithm: { header_hash: { header: X-User } }.
cookie_hashHash a named cookie. Configured as algorithm: { cookie_hash: { cookie: sid } }.

Target fields:

FieldTypeDefaultDescription
urlstringrequiredBackend URL.
weightint1Weight used by weighted algorithms.
backupboolfalseReserved for fallback. Excluded from normal selection.
groupstringDeployment group label (blue, green, canary).
priorityint5Routing priority (1 = highest, 10 = lowest). Read from X-Priority header when not set here.
zonestringAvailability zone or region label for locality-aware routing.
health_checkobjectActive health-check probe config. See Active health checks.
host_overridestringunsetOverride the upstream Host for this target. Default is the target URL's hostname.
disable_*_headerboolfalseSame per-header opt-outs as on proxy actions; see Forwarding headers.

Blue-green deployments

Route 100% of traffic to the named active group. Targets must have a group field set to blue or green.

action:
  type: load_balancer
  deployment_mode:
    mode: blue_green
    active: green
  targets:
    - url: https://blue.internal:8080
      group: blue
    - url: https://green.internal:8080
      group: green

Canary deployments

Route a configurable percentage of requests to canary targets (group canary); remaining traffic goes to primary targets.

action:
  type: load_balancer
  deployment_mode:
    mode: canary
    weight: 10            # 10% to canary
  targets:
    - url: https://primary.internal:8080
    - url: https://canary.internal:8080
      group: canary

websocket

Proxy WebSocket connections for real-time applications, chat systems, and streaming APIs.

origins:
  "ws.example.com":
    action:
      type: websocket
      url: wss://ws-backend.internal:8080
      subprotocols: [graphql-ws, graphql-transport-ws]
      max_message_size: 5242880
FieldTypeDefaultDescription
urlstringrequiredBackend WebSocket URL (ws:// or wss://)
subprotocolslistSupported WebSocket subprotocols
max_message_sizeint10485760Maximum message payload size in bytes (10 MB)

grpc

Proxy gRPC traffic for microservice architectures.

origins:
  "grpc.example.com":
    action:
      type: grpc
      url: grpcs://grpc-backend.internal:50051
      tls: true
      authority: grpc-backend.internal
      timeout_secs: 30
FieldTypeDefaultDescription
urlstringrequiredBackend gRPC URL (grpc://, grpcs://, http://, https://)
tlsboolfalseForce TLS regardless of URL scheme
authoritystringOverride the HTTP/2 :authority pseudo-header
timeout_secsint30Request timeout in seconds

ai_proxy

Route requests across LLM providers with automatic failover, cost tracking, and content-based routing. Supports 66 native providers behind one OpenAI-compatible API; the model name passes straight through, so any model a provider serves is reachable. For full details, see ai-gateway.md and providers.md.

origins:
  "ai.example.com":
    action:
      type: ai_proxy
      providers:
        - name: openai
          api_key: ${OPENAI_API_KEY}
          models: [gpt-4o, gpt-4o-mini, gpt-4-turbo]
          default_model: gpt-4o-mini
        - name: anthropic
          api_key: ${ANTHROPIC_API_KEY}
          models: [claude-sonnet-4-20250514, claude-haiku-4-5]
      routing: fallback_chain
      allowed_models: [gpt-4o, gpt-4o-mini, claude-haiku-4-5]
      blocked_models: []
      max_body_size: 4194304
FieldTypeDefaultDescription
providerslistrequiredConfigured upstream AI providers.
routingstring | objectround_robinRouting strategy. Either a flat string or {strategy: ..., ...}.
allowed_modelslistempty (allow all)Allow-list of model names.
blocked_modelslistBlock-list of model names. Takes precedence over allow-list.
max_body_sizeintMaximum request body size in bytes.
guardrailsobjectInput/output guardrails pipeline.
budgetobjectBudget enforcement configuration.
model_rate_limitsmapPer-model rate limit overrides keyed by model name.
per_surface_rate_limitsmapPer-surface rate limit overrides keyed by AI surface label (chat_completions, assistants, image_generation, ...).
max_concurrentmapMaximum concurrent in-flight requests per provider.
resilienceobjectPer-provider circuit breaker, outlier detection, and active health probes. Also hosts the LLM-aware knobs (retry_policy, llm_aware, content_policy_fallback); see ai-llm-aware-resilience.md.
shadowobjectSide-by-side eval: mirror each request to a second provider and log metrics.
ai_policyobjectOne sandboxed CEL expression over the AI decision pipeline (expression, on_error). See ai-policy-cel.md.
usage_sinkslist[]Destinations for completed-call usage records. The ledger sink (path, optional signing_seed_hex) writes a hash-chained, signable record. See ai-usage-ledger.md.

Routing strategies: round_robin, weighted, fallback_chain, random, lowest_latency, least_connections, cost_optimized, token_rate, least_token_usage, prefix_affinity, peak_ewma, sticky, race, cascade, cost_quality, outcome_aware. See ai-gateway.md for each; outcome_aware has its own page in ai-outcome-aware-routing.md.

default_model is a per-provider field, not an action-level field. Set it on each providers[] entry.

AI provider fields (providers[])

FieldTypeDefaultDescription
namestringrequiredUnique provider name used to reference this entry.
provider_typestringinferred from nameProvider type (openai, anthropic, google, etc.).
deploymentstringrequired for managed_modelCanonical proxy.model_host.deployments ID. Valid only when provider_type: managed_model.
api_keystringAPI key used to authenticate with the upstream.
base_urlstringprovider defaultOverride the upstream base URL. Validated at config load: non-http(s) schemes and private/loopback targets are rejected as SSRF risks unless allow_private_base_url is set.
allow_private_base_urlboolfalseAllow base_url to point at a loopback/private address (a local model server). The scheme check still applies.
modelslist[]Models served by this provider; empty defers to the provider catalog.
default_modelstringModel used when the request omits an explicit model.
model_mapmap{}Logical to upstream model name mapping.
weightint1Weight used by weighted routing strategies.
priorityintunsetPriority used by priority routing (lower runs first).
enabledbooltrueWhen false, this provider is skipped during routing.
max_retriesintunsetMaximum retries on transient upstream failures.
timeout_msintunsetRequest timeout in milliseconds.
organizationstringOrganization identifier for providers that scope keys per org.
api_versionstringAPI version header value (e.g. for Anthropic and Azure OpenAI).
no_prompt_trainingboolfalseMarks the provider safe for training-sensitive prompts. Requests carrying the x-sbproxy-disallow-prompt-training: true header only route to providers with this flag; a request with the header and no marked provider in the chain gets a 400 no_compliant_provider.

A managed_model provider must set a non-empty deployment and must not set api_key, base_url, or the legacy serve block. Conversely, deployment is rejected for every other provider type. Managed traffic resolves through the deployment runtime rather than an operator-supplied upstream URL.

Credentials

Per-team or per-app keys are declared under the origin-level credentials: block, a sibling of action. Each type: ai_provider credential maps a client-facing key to a provider, a per-key model gate, and attribution metadata. Clients send the credential's key in the Authorization header; the gateway matches it locally and swaps in the real upstream key before the call.

A virtual_keys: list inside the ai_proxy action is a hard config error: the config fails to load with a message pointing at migration-credentials.md.

origins:
  "ai.example.com":
    action:
      type: ai_proxy
      providers:
        - name: anthropic
          api_key: ${ANTHROPIC_API_KEY}
          models: [claude-haiku-4-5, claude-sonnet-4-5]
    credentials:
      - name: team-frontend
        type: ai_provider
        provider: anthropic
        key: ${TEAM_FRONTEND_KEY}
        models:
          allow: [claude-haiku-4-5]
        attrs:
          tags: [team-frontend]
          budget:
            max_tokens: 500000
            max_cost_usd: 10.0
        policies:
          - type: rate_limit
            rpm: 30
FieldTypeDefaultDescription
namestringrequiredStable name, unique within its scope. Identifies the credential in metrics and logs.
typestringrequiredCredential kind. ai_provider for AI gateway keys; other kinds are bearer, api_key, jwt, basic, oidc_client, outbound_token_exchange, outbound_client_credentials.
providerstringProvider this key routes to. Matches an entry in the action's providers: list.
keystringClient-facing key material. Accepts ${ENV} and secret reference URIs.
models.allow / models.denylistPer-key model gate, enforced with a 403 before any upstream call. Stacks on the origin-level allow-list; most restrictive wins.
attrsobjectAttribution metadata (project, tags, budget, ...) surfaced as attribution labels (including api_key_id) on the sbproxy_ai_*_attributed_total metrics. The budget here is attribution, not an enforced ceiling; enforced spend caps live in the action-level budget: block.
policieslistSub-policies that fire when this credential matches. {type: rate_limit, rpm: <n>} lowers to an enforced per-key requests-per-minute cap; there is no per-key tokens-per-minute knob. {type: require_pii_redaction, rules: [...]} gates dispatch on active PII redaction.
route_to_modelstringPin the upstream model field; the client-supplied value is ignored.
inject_toolslistReplace the request's tools array with these provider-native entries.

See examples/ai-virtual-keys/sb.yml for a runnable two-team setup and migration-credentials.md for the field-by-field migration from the legacy shape.

Budget (budget)

FieldTypeDefaultDescription
limitslist[]Budget rules. See below.
on_exceedstringblockAction when a limit is hit: block, log, downgrade.
soft_landingobjectunsetGraceful degradation before the cap (warn_at, downgrade_at, downgrade_to). See ai-predictive-budget.md.

Each limits[] entry:

FieldTypeDefaultDescription
scopestringrequiredworkspace, api_key, user, model, origin, or tag.
max_tokensintunsetMaximum tokens for this scope.
max_cost_usdfloatunsetMaximum spend in USD for this scope.
periodstringunsetTime window: daily, monthly, total.
downgrade_tostringModel to swap to when on_exceed: downgrade.

Per-model rate limits (model_rate_limits)

Keyed by model name; each entry has requests_per_minute and tokens_per_minute.

model_rate_limits:
  gpt-4o:
    requests_per_minute: 60
    tokens_per_minute: 200000
FieldTypeDefaultDescription
requests_per_minuteintunsetRequests-per-minute cap for this model.
tokens_per_minuteintunsetTokens-per-minute cap for this model.

Per-surface rate limits (per_surface_rate_limits)

Keyed by AI surface label. The labels are the same stable strings emitted on the sbproxy_ai_surface_requests_total metric: chat_completions, models, embeddings, assistants, threads, batches, fine_tuning, files, realtime, image_generation, image_edits, image_variations, audio_transcription, audio_speech, moderations, reranking. Surfaces without an entry are uncapped. When the cap is hit, the proxy returns 429 before any upstream call.

per_surface_rate_limits:
  image_generation:
    requests_per_minute: 30
  audio_speech:
    requests_per_minute: 60
  chat_completions:
    requests_per_minute: 600
FieldTypeDefaultDescription
requests_per_minuteintunsetRequests-per-minute cap for this surface. Sliding one-minute window, shared globally across the process.

Guardrails (guardrails)

FieldTypeDefaultDescription
inputlist[]Guardrails evaluated against the incoming request body.
outputlist[]Guardrails evaluated against the model output.
meshobjectunsetRuns input detectors as a cascade and fuses verdicts under a quorum rule (block_threshold, redact_on_flag, cache, cache_capacity, latency_budget_ms). See ai-guardrail-mesh.md.

Each input / output entry is an object with a type field and type-specific config. Built-in types: pii, secrets, injection (alias prompt_injection), toxicity, jailbreak, content_safety, schema, regex, regex_guard, context_poisoning, agent_alignment. See ai-gateway.md for per-guardrail fields.

See the AI Gateway Guide for CEL selectors, Lua hooks, guardrails, context window validation, per-request attribution, and streaming behavior.

Resilience (resilience)

Three independent signals that eject misbehaving providers from the routing pool. Any signal alone is enough to skip a provider; when every provider is ejected, the router falls back to the unfiltered enabled list rather than returning no provider at all.

resilience:
  circuit_breaker:
    failure_threshold: 5      # consecutive 5xx / transport errors before opening
    success_threshold: 2      # half-open successes before closing
    open_duration_secs: 30    # cooldown before half-open probe
  outlier_detection:
    threshold: 0.5            # eject when failure rate >= 50%
    window_secs: 60           # sliding window
    min_requests: 5           # minimum sample before ejecting
    ejection_duration_secs: 30
  health_check:
    path: /models             # GET endpoint probed on each provider
    interval_secs: 30
    timeout_ms: 5000
    unhealthy_threshold: 3
    healthy_threshold: 2

When resilience is set, retries fan across providers up to min(providers.len(), 5) attempts; ejected providers are skipped on the second and later attempts.

The block also accepts the LLM-aware keys: retry_policy (per-failure-class retry counts, e.g. rate_limit: 3), llm_aware.context_compress plus llm_aware.completion_reserve_tokens (fit an over-long prompt to the model's window before dispatch), and content_policy_fallback (route a content-policy refusal to the next provider in priority order). Semantics and the failure-cause table are in ai-llm-aware-resilience.md.

Shadow (shadow)

Mirrors each request to a second provider concurrently. The primary's response is what the client sees; the shadow body is drained and metrics are logged at target: sbproxy_ai_shadow (status, latency, prompt/completion tokens, finish_reason). Useful for prompt regression checks before swapping a primary model.

shadow:
  provider: anthropic         # must also appear in `providers`
  model: claude-haiku-4-5   # optional override; defaults to client's model
  sample_rate: 0.1            # mirror 10% of traffic; 1.0 mirrors all
  timeout_ms: 30000

Race strategy (routing.strategy: race)

Fans the request out to every eligible provider in parallel; returns the first 2xx and cancels the in-flight losers. Failures still feed resilience so persistently slow providers eventually drop out of the eligible set. Use sparingly: race fans up your provider spend by N until one wins.

routing:
  strategy: race
providers:
  - name: openai
    api_key: ${OPENAI_API_KEY}
  - name: anthropic
    api_key: ${ANTHROPIC_API_KEY}

graphql

Proxy GraphQL requests to an upstream HTTP endpoint with optional query depth limiting and introspection control.

origins:
  "graphql.example.com":
    action:
      type: graphql
      url: https://graphql-backend.internal/graphql
      max_depth: 10
      allow_introspection: false
      validate_queries: true
FieldTypeDefaultDescription
urlstringrequiredBackend GraphQL endpoint URL (http:// or https://).
max_depthint0Maximum query nesting depth. 0 means unlimited.
allow_introspectionbooltrueWhen false, introspection queries are rejected.
validate_queriesboolfalseWhen true, validate incoming GraphQL queries.

storage

Serve files from an object storage backend (S3, GCS, Azure Blob, or local filesystem). The OSS runtime builds an object_store backend at config-load time and serves GET and HEAD requests with content metadata, byte-range responses, and optional index_file fallback for directory paths. Unsupported methods return 405, missing objects return 404, and transient backend failures return 502.

origins:
  "static.example.com":
    action:
      type: storage
      backend: s3
      bucket: my-public-assets
      prefix: web/
      index_file: index.html
FieldTypeDefaultDescription
backendstringrequiredOne of s3, gcs, azure, local.
bucketstringBucket name. Required for s3, gcs, and azure.
prefixstringKey prefix prepended to request paths. May not contain .. segments or NUL bytes.
pathstringLocal filesystem root. Required for backend: local. May not contain .. segments or NUL bytes.
index_filestringIndex file served for directory requests (e.g. index.html). May not contain .. segments or NUL bytes.

Cloud backends use the standard credential discovery for their provider (AWS_*, Google, or Azure environment), plus optional S3 region and endpoint overrides in the runtime config. The HTTP/3 action dispatcher is still disabled with the rest of HTTP/3 support; storage is served over HTTP/1.1 and HTTP/2 today.

a2a

Proxy requests to an Agent-to-Agent (A2A) endpoint that speaks the Google A2A protocol. The agent card metadata can be cached locally for discovery.

origins:
  "agent.example.com":
    action:
      type: a2a
      url: https://agent-backend.internal/a2a
      agent_card:
        name: SearchAgent
        version: "1.0"
        capabilities: [text, tool-use]
FieldTypeDefaultDescription
urlstringrequiredUpstream agent URL.
agent_cardobjectCached A2A agent card (free-form JSON).

Authentication

The authentication block is a sibling of action, not nested inside it. It controls who can access the origin. SBproxy ships ten built-in auth providers: api_key, basic_auth, bearer, jwt, digest, forward_auth, bot_auth, cap, oidc, and noop.

bot_auth verifies cryptographically-signed AI agents per RFC 9421 + the IETF Web Bot Auth draft. Full reference: web-bot-auth.md.

Anything else falls through to the inventory-based auth plugin registry, so a linked third-party crate can register additional types (oauth, oauth_introspection, oauth_client_credentials, ext_authz, biscuit, saml, ...) without patching the OSS engine. Plugins register on the typed AuthPluginRegistration channel and surface through the standard authentication.type config field.

api_key

Authenticate requests with an API key. Keys are checked in the X-Api-Key header by default; an optional query_param lets clients pass keys via the URL. Typical fit: machine-to-machine API access.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    authentication:
      type: api_key
      api_keys:
        - ${API_KEY_1}
        - ${API_KEY_2}
      query_param: api_key
FieldTypeDefaultDescription
typestringrequiredMust be api_key
api_keyslistrequiredAccepted API keys
header_namestringX-Api-KeyHeader carrying the API key
query_paramstringWhen set, keys can be supplied via the named URL query parameter

Test with:

curl -H "Host: api.example.com" -H "X-Api-Key: your-key-here" http://localhost:8080/

basic_auth

HTTP Basic Authentication with username/password pairs. Fits simple internal services and admin panels.

origins:
  "admin.example.com":
    action:
      type: proxy
      url: https://admin-backend.internal:8080
    authentication:
      type: basic_auth
      users:
        - username: admin
          password: ${ADMIN_PASSWORD}
        - username: readonly
          password: ${READONLY_PASSWORD}
      realm: "Admin Panel"
FieldTypeDefaultDescription
typestringrequiredMust be basic_auth
userslistrequiredUsername/password pairs
realmstringOptional realm shown in the WWW-Authenticate challenge

bearer

Authenticate with Bearer tokens in the Authorization header. The default for token-based service auth.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    authentication:
      type: bearer
      tokens:
        - ${SERVICE_TOKEN_1}
        - ${SERVICE_TOKEN_2}
FieldTypeDefaultDescription
typestringrequiredMust be bearer
tokenslistrequiredAccepted bearer tokens (each entry is either the raw secret or {secret, dpop_jkt, ...})
require_dpopboolfalseWhen true, every accepted token MUST come with a valid RFC 9449 DPoP proof whose jkt matches the token entry's dpop_jkt metadata. Tokens without dpop_jkt metadata fail closed.

Sender-constrained Bearer (RFC 9449)

DPoP binds an opaque bearer token to a proof-of-possession key so a stolen token alone is not enough to access the resource. The operator stamps the JWK thumbprint of the expected key on each bearer entry; the proxy reads the DPoP: header on every request and verifies the proof against the stamped thumbprint.

authentication:
  type: bearer
  require_dpop: true
  tokens:
    - secret: ${SERVICE_TOKEN_1}
      dpop_jkt: "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
    - secret: ${SERVICE_TOKEN_2}
      dpop_jkt: "8WGoq1lXk-3z7AIuS-XwSeUGzqQ3LtIMOvbf2bZj0Vk"

The dpop_jkt value is the RFC 7638 SHA-256 thumbprint of the client's DPoP signing key, base64url-no-pad. Deriving it once per client is a one-shot operator step (most identity systems publish it alongside the client's other registration data).

jwt

Validate JSON Web Tokens. Supports JWKS endpoints for key rotation and claims validation. Pick this for OAuth2/OIDC-protected APIs.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    authentication:
      type: jwt
      jwks_url: https://auth.example.com/.well-known/jwks.json
      issuer: https://auth.example.com
      audience: my-api
      algorithms: [RS256]
      required_claims:
        scope: api:read
FieldTypeDefaultDescription
typestringrequiredMust be jwt
secretstringHMAC signing secret (HS256/HS384/HS512)
jwks_urlstringURL to fetch JWKS from (RS / ES / PS family)
issuerstringRequired iss claim value
audiencestringRequired aud claim value
algorithmslistinferredAllowed signing algorithms. Defaults to HS256/HS384/HS512 with secret, RS256 with jwks_url.
required_claimsmapClaims that must be present and equal to the configured value.
require_dpopboolfalseWhen true, the JWT MUST come with a valid RFC 9449 DPoP proof whose jkt matches the token's cnf.jkt claim. Tokens without a cnf.jkt claim fail closed.
require_mtls_boundboolfalseWhen true, the JWT's cnf.x5t#S256 claim MUST match the SHA-256 thumbprint of the inbound TLS client cert (RFC 8705 mutual-TLS-bound tokens).

The list must contain at least one entry; an empty list rejects all tokens. Bearer tokens must be supplied via Authorization: Bearer <jwt>.

Sender-constrained JWT (RFC 9449 + RFC 8705)

Both require_dpop and require_mtls_bound may be set together on the same provider; the request must satisfy BOTH constraints. The two constraints are independent:

  • DPoP (RFC 9449) binds the token to a proof-of-possession key the client signs with on every request. The token's cnf.jkt claim is the SHA-256 thumbprint of that key; the proxy reads the DPoP: header and verifies.
  • mTLS-bound (RFC 8705) binds the token to the SHA-256 thumbprint of the TLS client cert the resource server saw on the connection. The token's cnf.x5t#S256 claim carries the thumbprint; the proxy compares against the inbound client cert.
authentication:
  type: jwt
  jwks_url: https://auth.example.com/.well-known/jwks.json
  issuer: https://auth.example.com
  audience: my-api
  require_dpop: true
  require_mtls_bound: true

Both flags default to false so existing JWT configurations keep their unbound semantics. Turn them on per-route as the issuer starts minting cnf.jkt / cnf.x5t#S256 tokens.

digest

HTTP Digest Authentication (RFC 7616). The right pick when a legacy system insists on digest auth. The stored password is the HA1 hash, MD5(username:realm:password), not the plaintext password.

origins:
  "legacy.example.com":
    action:
      type: proxy
      url: https://legacy-backend.internal:8080
    authentication:
      type: digest
      realm: "Legacy"
      users:
        - username: alice
          password: ${ALICE_HA1}
FieldTypeDefaultDescription
typestringrequiredMust be digest.
realmstringrequiredRealm string sent in the WWW-Authenticate challenge.
userslist or maprequiredAccepted users. Either a list of {username, password} objects, or a map of username: ha1_hex.

forward_auth

Delegate authentication to an external service. SBproxy sends a subrequest to the auth service and uses the response status to allow or deny the original request. The right choice when auth logic lives in its own service.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    authentication:
      type: forward_auth
      url: https://auth.internal/verify
      method: GET
      timeout: 5000
      headers_to_forward: [Authorization, Cookie]
      trust_headers: [X-User-ID, X-User-Email, X-User-Roles]
      success_status: 200
FieldTypeDefaultDescription
typestringrequiredMust be forward_auth
urlstringrequiredExternal auth service URL
methodstringGETHTTP method for the subrequest
timeoutintSubrequest timeout in milliseconds
headers_to_forwardlistHeaders to copy from the original request. Alias: forward_headers.
trust_headerslistHeaders from the auth response to inject into the upstream request
success_statusint | list200Status code(s) that mean "authenticated". A list is accepted, but only the first element is used.

noop

The no-op auth provider accepts every request without checking credentials. Set this explicitly to mark an origin as unauthenticated, so the intent is obvious in the config.

authentication:
  type: noop

Per-credential metadata

Every inbound auth provider accepts an optional metadata block on each credential entry. When a credential matches, its metadata travels onto the request principal and surfaces in the access log under principal_kind, in metrics labels, and in policy scripts that read principal.attrs.*. The metadata fields are:

FieldTypeDescription
projectstringProject the credential belongs to. Drives the project column on the access log and metric labels.
userstringUser the credential represents or its owner.
teamstringTeam or cost-center grouping.
tagslist of stringsOperator-supplied tags. Stamped on principal.attrs.tags.
metadatamap of stringsFree-form metadata copied off the credential. Stored as a sorted map for deterministic log lines.

The block is optional on every provider; existing configs that use the bare-string shorthand (a list of plain secrets) continue to parse unchanged. Operators opt in per credential.

Bearer

The full-shape entry replaces a bare string. Mixed lists are allowed.

authentication:
  type: bearer
  tokens:
    - "shared-token-no-metadata"
    - secret: ${SERVICE_TOKEN_1}
      project: foundation
      team: platform
      tags: [internal]
      metadata:
        cost_center: eng-001

API key

authentication:
  type: api_key
  header_name: X-Api-Key
  api_keys:
    - "bare-key"
    - secret: ${TEAM_FRONTEND_KEY}
      project: foundation
      team: frontend

Basic auth

Metadata fields sit flat alongside username and password on each user entry.

authentication:
  type: basic_auth
  realm: "Admin Panel"
  users:
    - username: admin
      password: ${ADMIN_PASSWORD}
      project: foundation
      team: platform
      tags: [admin]

JWT

The JWT provider takes a single nested attrs: block (rather than per-token metadata) because the secret material is the JWKS or shared secret, not a list of static tokens. The optional roles_claim: list names the claims to copy onto principal.attrs.roles; the first claim present wins.

authentication:
  type: jwt
  jwks_url: https://auth.example.com/.well-known/jwks.json
  issuer: https://auth.example.com
  audience: my-api
  attrs:
    project: foundation
    team: platform
  roles_claim:
    - roles
    - groups

OIDC

Same nested attrs: shape as JWT.

authentication:
  type: oidc
  authorization_endpoint: https://idp.example.com/authorize
  token_endpoint: https://idp.example.com/oauth/token
  jwks_uri: https://idp.example.com/.well-known/jwks.json
  issuer: https://idp.example.com
  client_id: sbproxy
  client_secret: ${OIDC_CLIENT_SECRET}
  cookie_secret: ${OIDC_COOKIE_SECRET}
  attrs:
    project: foundation
    team: platform

The access log records the matched principal's source under the principal_kind column (bearer, api_key, basic_auth, jwt, oidc, virtual_key, bot_auth, cap, forward_auth, plugin, or none when no provider is configured). See access-log.md for the full column reference.


Policies

Policies are evaluated before the action runs. They enforce rate limits, security rules, and access controls. The policies field is a sibling of action and is an array of policy objects.

SBproxy ships twenty-seven policy types: rate_limiting, rate_limit_budget, ip_filter, expression, waf, ddos, csrf, security_headers, request_limit, sri, assertion, request_validator, content_digest, concurrent_limit, ai_crawl_control, object_authz, exposed_credentials, page_shield, dlp, openapi_validation, prompt_injection_v2, http_framing, agent_class, a2a, semantic_constraint, peer_pricing_preflight, and agent_budget. This page documents the most common ones; the rest have their own pages.

rate_limiting

Rate limit clients to prevent abuse and protect backend resources. Uses a token bucket by default (in-process) or a fixed-window counter (when an L2 Redis backend is configured).

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    policies:
      - type: rate_limiting
        requests_per_minute: 60
        burst: 10
        algorithm: token_bucket
        whitelist:
          - 10.0.0.0/8

Clients exceeding the limit receive 429 Too Many Requests with a Retry-After header.

FieldTypeDefaultDescription
typestringrequiredMust be rate_limiting
requests_per_secondfloatPer-second token refill rate
requests_per_minutefloatPer-minute token refill rate (mutually exclusive with requests_per_second)
burstintderived from rateMaximum burst capacity
algorithmstringtoken_bucketAlgorithm hint: token_bucket, fixed_window. The runtime picks based on whether an L2 backend is attached.
headersobjectX-RateLimit-* and Retry-After header configuration
whitelistlistIPs/CIDRs exempt from rate limiting

Distributed rate limiting: a single-instance deployment tracks counters in memory. For multi-instance deployments, configure an L2 Redis cache so counters are shared across all proxy replicas:

proxy:
  l2_cache_settings:
    driver: redis
    params:
      dsn: redis://redis.internal:6379/0

ip_filter

Allow or block requests by client IP address or CIDR range. Useful for locking down internal services or blocking known bad actors.

policies:
  - type: ip_filter
    whitelist:
      - 10.0.0.0/8
      - 192.168.1.0/24
      - 172.16.0.0/12
    blacklist:
      - 10.0.0.99/32
FieldTypeDefaultDescription
typestringrequiredMust be ip_filter
whitelistlistCIDR ranges that are explicitly permitted. Empty allows everything.
blacklistlistCIDR ranges that are explicitly denied.

If whitelist is non-empty, the client IP must match at least one entry. If blacklist is non-empty, the client IP must not match any entry. Both lists may be used together.

expression

CEL expression that evaluates to allow or deny a request. Pick this for custom access control logic that goes beyond simple IP or key checks.

policies:
  - type: expression
    expression: 'request.headers["x-internal"] == "true"'
    deny_status: 403
    deny_message: "internal traffic only"
FieldTypeDefaultDescription
typestringrequiredMust be expression
expressionstringrequiredCEL expression returning a boolean. Alias: cel_expr.
deny_statusint403HTTP status code when denied. Alias: status_code.
deny_messagestring"forbidden by policy"Body returned with the deny status code.

Expression policies evaluate CEL only. For Lua-driven access control, use a request modifier with a lua_script.

request_validator

Validate request bodies against a JSON Schema at the edge. Inbound payloads that fail validation are rejected with a configurable status (default 400) and a typed JSON error body, before they reach the upstream.

policies:
  - type: request_validator
    content_types: [application/json]   # default
    status: 400                         # default
    error_content_type: application/json
    schema:
      type: object
      required: [name, age]
      properties:
        name: { type: string, minLength: 1 }
        age:  { type: integer, minimum: 0 }
      additionalProperties: false
FieldTypeDefaultDescription
schemaJSONrequiredJSON Schema document. Compiled once at config-load.
content_typesarray[application/json]Media types this policy applies to. Other types pass through untouched. Matched case-insensitively against the leading media type (parameters are ignored).
statusint400HTTP status returned on validation failure.
error_bodystringstructured JSONOptional rejection body. Default is {"error":"...","detail":"<location>"} with no echoed payload.
error_content_typestringapplication/jsonContent-Type for the rejection body.

The proxy buffers the request body locally until validation completes, then either releases it as one chunk to the upstream or aborts with the configured rejection. Remote $ref resolution in schemas is disabled at the workspace level so a malicious schema cannot become an SSRF primitive. The rejection body never echoes the offending payload back to the caller, only the JSON path where validation failed.

See example 81.

openapi_validation

Load an OpenAPI 3.0 document at startup and validate each request body against the matching operation's requestBody schema. Requests whose path + method are not described in the spec, or whose Content-Type has no schema, are passed through. Full reference: openapi-validation.md.

policies:
  - type: openapi_validation
    mode: enforce             # or 'log'
    status: 422               # status returned on enforce-mode rejection
    spec:
      openapi: "3.0.3"
      info: {title: my-api, version: "1.0"}
      paths:
        "/users/{id}":
          post:
            requestBody:
              required: true
              content:
                application/json:
                  schema:
                    type: object
                    required: [name]
                    additionalProperties: false
                    properties:
                      name: {type: string, minLength: 1}
                      age:  {type: integer, minimum: 0, maximum: 150}
FieldTypeDefaultDescription
specobjectrequired*Inline OpenAPI document. *One of spec or spec_file is required.
spec_filestringrequired*Path to an OpenAPI document on disk (.json or .yaml).
modestringenforceenforce rejects mismatched bodies; log warns and forwards.
statusint400Status returned in enforce mode on validation failure.
error_bodystringautoOptional rejection body. Defaults to a JSON object naming the failing JSON pointer.
error_content_typestringapplication/jsonContent-Type for the rejection body.

OpenAPI path templates compile to anchored regexes at startup; per-operation schemas compile once. The rejection body lists only the offending JSON pointer, not the value itself, to keep the surface area an attacker can probe small.

See example 97.

concurrent_limit

Cap in-flight requests per key. Distinct from rate_limiting, which throttles RPS. Concurrent limits protect backends with low concurrency budgets: legacy SOAP services, DB-bound endpoints, GPU inference workers, anywhere slow requests pile up faster than they drain.

policies:
  - type: concurrent_limit
    max: 50
    key: api_key      # or 'ip', or 'origin' (default)
    status: 503
    error_body: '{"error":"too many concurrent requests"}'
FieldTypeDefaultDescription
maxintrequiredMaximum concurrent requests per key. Must be > 0.
keystringoriginBucket strategy: origin (one global counter for the route), ip (per client IP), or api_key (per X-Api-Key or Bearer token).
statusint503HTTP status when the limit is exceeded.
error_bodystringunsetOptional response body for rejections.

Each accepted request takes a permit; the permit is released when the request finishes (success, error, or client disconnect). Counters use a sharded DashMap so contention across keys is bounded.

See example 82.

ai_crawl_control

Pay Per Crawl: respond with 402 Payment Required to AI crawlers that arrive without a valid Crawler-Payment token. Each token redeems once. Full reference: ai-crawl-control.md.

policies:
  - type: ai_crawl_control
    price: 0.001
    currency: USD
    crawler_user_agents: [GPTBot, ChatGPT-User, ClaudeBot, anthropic-ai, Google-Extended, PerplexityBot, CCBot]
    valid_tokens:
      - tok_a89be2f1
      - tok_b7cf012e
FieldTypeDefaultDescription
pricefloatunsetPrice emitted in the challenge body and the price= challenge parameter.
currencystringUSDISO-4217 code surfaced in the challenge.
headerstringcrawler-paymentHeader carrying the payment token.
crawler_user_agentslistmajor AI crawler defaultsCase-insensitive substring matches against User-Agent. Empty list treats every GET/HEAD as a crawler.
valid_tokenslist[]Seeds the in-memory single-use ledger. Enterprise replaces this with an HTTP-callable ledger.

Only GET and HEAD are subject to charging. POST/PUT/PATCH/DELETE bypass.

exposed_credentials

Detect requests carrying a known-leaked password against a static exposure list. Tags the upstream request with exposed-credential-check: leaked-password (default) or rejects the request outright. Full reference and rollout guidance: exposed-credentials.md.

policies:
  - type: exposed_credentials
    action: tag                       # or "block"
    passwords:                        # plaintext, hashed at compile-time
      - password
      - password123
    sha1_hashes:                      # uppercase or lowercase hex
      - 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
    sha1_file: /etc/sbproxy/leaked-sha1.txt   # one hash per line; `#` comments
FieldTypeDefaultDescription
providerstringstaticOSS only ships static. Enterprise extends with hibp (k-anonymity range query).
actionstringtagtag stamps the configured header on the upstream request. block returns 403.
headerstringexposed-credential-checkHeader name when action: tag.
passwordslist[]Plaintext passwords. Hashed at compile time; the source strings are not retained on the policy.
sha1_hasheslist[]Inline SHA-1 hex hashes.
sha1_filestringunsetPath to a file with one SHA-1 hex hash per line.

The policy refuses to compile when no list is supplied. SHA-1 uppercase hex matches the format HIBP returns from its range queries, so a downloaded list drops onto disk without preprocessing.

page_shield

Stamps a Content Security Policy header on every proxied response and runs an intake endpoint at /__sbproxy/csp-report for browser-emitted violation reports. Reports are logged structured under the sbproxy::page_shield tracing target so logpush sinks (and the enterprise Connection Monitor, F3.20) pick them up.

policies:
  - type: page_shield
    mode: report-only           # or "enforce"
    directives:
      - "default-src 'self'"
      - "script-src 'self' https://cdn.example"
      - "img-src 'self' https: data:"
    report_path: /__sbproxy/csp-report   # default
    report_to_group: csp-endpoint        # optional; emits report-to too
    respect_upstream: false              # yield to an upstream-supplied CSP
FieldTypeDefaultDescription
modestringreport-onlyreport-only emits Content-Security-Policy-Report-Only. enforce emits Content-Security-Policy.
directiveslistrequired, non-emptyEach entry is a complete CSP directive (default-src 'self'). Joined with ; .
report_pathstring/__sbproxy/csp-reportOverride the intake path. Used in the auto-appended report-uri directive.
report_to_groupstringunsetWhen set, the policy also emits report-to <name> for the modern Reporting API.
respect_upstreamboolfalseWhen true and the upstream already emits a CSP header, the policy yields and does not write its own.

The intake accepts up to 64 KiB per report via POST /__sbproxy/csp-report and returns 204 No Content. The header is applied to proxied responses; static / redirect / mock actions short-circuit before the response-header phase and bypass injection.

dlp

Data Loss Prevention scan over the request URI and headers. Matches against the configured detector catalogue (or every default when detectors: []) and either tags the upstream request with dlp-detection: <names> (action: tag, default) or rejects with 403 (action: block).

policies:
  - type: dlp
    action: tag                  # or "block"
    detectors: []                # empty = enable every default detector
    rules:                       # optional custom rules layered on top
      - name: internal_ticket
        pattern: '\bTICKET-\d{6}\b'
        replacement: '[REDACTED:TICKET]'
        anchor: 'TICKET-'

Default detectors: email, us_ssn, credit_card, phone_us, ipv4, openai_key, anthropic_key, aws_access, github_token, slack_token, iban.

FieldTypeDefaultDescription
detectorslist[] (all defaults)Detector names to enable. Unknown names fail at compile-time.
actionstringtagtag stamps <header>: <detector_csv> on the upstream. block returns 403.
directionstringrequestrequest is the only path enforced today; response and both are accepted for forward compatibility.
headerstringdlp-detectionHeader name when action: tag.
ruleslist[]Custom regex rules layered on top of the catalogue. Same shape as the pii.rules block on ai_proxy origins.

The scan covers the request URI (path + query) and request headers; auth-class headers (Authorization, Cookie, Set-Cookie) are excluded so tokens carried by design don't self-flag. Body scanning is on the roadmap; the existing pii: block on ai_proxy origins handles request-body redaction with the same regex catalogue today.

prompt_injection_v2

Successor to the v1 prompt_injection heuristic. The v2 policy splits detection from enforcement: a swappable detector returns a score in [0.0, 1.0] plus a categorical label, and the policy maps the score onto an action. The OSS build registers a heuristic detector by default (detector: heuristic-v1) so the policy works out of the box. Future builds register additional detectors (e.g. an ONNX classifier) without touching the policy core.

policies:
  - type: prompt_injection_v2
    action: tag                         # tag (default) | block | log
    detector: heuristic-v1              # default; lookup is link-time
    threshold: 0.5                      # fires when score >= threshold
    score_header: x-prompt-injection-score
    label_header: x-prompt-injection-label
    block_body: 'prompt injection detected'
    block_content_type: text/plain
FieldTypeDefaultDescription
detectorstringheuristic-v1Detector name. Resolved against the inventory registry; unknown names fail at compile time.
thresholdfloat0.5Score threshold in [0.0, 1.0]; the policy fires when score >= threshold.
actionstringtagtag stamps the score / label headers on the upstream. block returns 403 with block_body. log writes a structured warn under sbproxy::prompt_injection_v2.
score_headerstringx-prompt-injection-scoreHeader carrying the numeric score (formatted as "%.3f") on action: tag.
label_headerstringx-prompt-injection-labelHeader carrying clean / suspicious / injection on action: tag.
block_bodystringprompt injection detectedResponse body returned on action: block.
block_content_typestringtext/plainContent-Type for the block body.

The OSS scaffold scans the request URI + non-auth headers (Authorization, Cookie, Set-Cookie are excluded so tokens carried by design don't self-flag) at request-filter time. Tag mode stamps the score / label headers via the existing trust-headers channel before upstream_request_filter builds the upstream request; block mode rejects with 403 immediately. Body-aware detection (the prompt typically lives in the JSON body) is on the roadmap and lands with the ONNX classifier follow-up. See prompt-injection-v2.md for the trait shape, the eval harness, and how to register a custom detector.

waf

Web Application Firewall. Built-in patterns cover SQL injection, XSS, and path traversal. Custom rules can extend behavior.

policies:
  - type: waf
    owasp_crs:
      enabled: true
    action_on_match: block
    test_mode: false
    fail_open: false
    custom_rules: []
FieldTypeDefaultDescription
typestringrequiredMust be waf
owasp_crsobjectOWASP Core Rule Set configuration.
action_on_matchstring"block"Action when a rule matches: block, log.
test_modeboolfalseIf true, log matches but do not block.
fail_openboolfalseIf true, allow requests through on WAF engine failure.
custom_ruleslistCustom WAF rules (regex patterns or JS-defined matchers).

ddos

DDoS protection with per-IP rate tracking and temporary blocks.

policies:
  - type: ddos
    requests_per_second: 100
    block_duration_secs: 300
    whitelist:
      - 10.0.0.0/8
FieldTypeDefaultDescription
typestringrequiredMust be ddos
requests_per_secondint100Per-IP threshold that triggers blocking.
block_duration_secsint300Duration in seconds an IP stays blocked once the threshold trips.
whitelistlist[]CIDR ranges that bypass DDoS checks.
detectionobjectGo-compat nested form. When detection.request_rate_threshold is set, it overrides requests_per_second.
mitigationobjectGo-compat nested form. When mitigation.block_duration is set as a Go duration string (10s, 5m, 1h), it overrides block_duration_secs.

csrf

Cross-Site Request Forgery protection for web applications that accept form submissions.

policies:
  - type: csrf
    secret_key: ${CSRF_SECRET}
    cookie_name: csrf_token
    header_name: X-CSRF-Token
    methods: [POST, PUT, DELETE, PATCH]
    safe_methods: [GET, HEAD, OPTIONS]
    cookie_path: /
    cookie_same_site: Lax
    exempt_paths: [/api/webhooks, /api/health]
FieldTypeDefaultDescription
typestringrequiredMust be csrf
secret_keystringrequiredHMAC key used to sign CSRF tokens. Alias: secret.
header_namestringX-CSRF-TokenHeader carrying the CSRF token
cookie_namestringcsrf_tokenCookie carrying the canonical CSRF token
methodslistMethods that require CSRF token validation. When empty, falls back to "anything not in safe_methods".
safe_methodslist[GET, HEAD, OPTIONS]Methods exempt from CSRF checking
cookie_pathstringCookie path
cookie_same_sitestringSameSite attribute (Strict, Lax, None)
exempt_pathslistPaths exempt from CSRF checking

request_limit

Cap request body size, header count, header value size, URL length, and query string length. Any field left unset means that dimension is not checked.

policies:
  - type: request_limit
    max_body_size: 1048576
    max_header_count: 50
    max_header_size: 8KB
    max_url_length: 2048
    max_query_string_length: 1024
FieldTypeDefaultDescription
max_body_sizeintunsetMaximum request body size in bytes.
max_header_countintunsetMaximum number of request headers. Alias: max_headers_count.
max_header_sizeint or stringunsetMaximum size of a single header value. Strings like "4KB" or "1MB" are accepted.
max_url_lengthintunsetMaximum URL length in characters.
max_query_string_lengthintunsetMaximum query string length in characters.
max_request_sizeint or stringunsetGo-compat overall request size cap. Same string-or-number rules as max_header_size.
size_limitsobjectGo-compat nested form. When set, fields here are merged into the policy at load time.

security_headers

Inject security headers into every response to harden browser security.

policies:
  - type: security_headers
    headers:
      - name: Strict-Transport-Security
        value: "max-age=31536000; includeSubDomains; preload"
      - name: X-Frame-Options
        value: DENY
      - name: X-Content-Type-Options
        value: nosniff
      - name: Referrer-Policy
        value: strict-origin-when-cross-origin
      - name: Permissions-Policy
        value: "camera=(), microphone=(), geolocation=()"
    # Optional: detailed CSP block for nonce / dynamic routes only.
    content_security_policy:
      policy: "default-src 'self'; script-src 'self' https://cdn.example.com"
      enable_nonce: false
      report_only: false
      report_uri: ""

headers is a list of {name, value} pairs for any response header (HSTS, Cross-Origin-*, COEP/COOP/CORP, Referrer-Policy, Permissions-Policy, and so on). The optional content_security_policy block is for advanced CSP behavior only: per-request nonce injection, report-only mode, per-route overrides. For a plain CSP without nonce or dynamic routes, add a Content-Security-Policy entry to headers directly.

FieldTypeDefaultDescription
typestringrequiredMust be security_headers.
headerslist[]Canonical {name, value} pairs to inject. Takes precedence over the legacy flat fields below.
content_security_policystring or objectCSP. Either a plain policy string or an object (see below).
x_frame_optionsstringLegacy flat shortcut. Deprecated.
x_content_type_optionsstringLegacy flat shortcut. Deprecated.
x_xss_protectionstringLegacy flat shortcut. Deprecated.
referrer_policystringLegacy flat shortcut. Deprecated.
permissions_policystringLegacy flat shortcut. Deprecated.
strict_transport_securitystringLegacy flat HSTS shortcut. Deprecated.

When content_security_policy is an object, it accepts:

FieldTypeDefaultDescription
policystring""The CSP policy string.
enable_nonceboolfalseWhen true, generate a per-request nonce and inject it into script-src / style-src directives.
report_onlyboolfalseWhen true, emit Content-Security-Policy-Report-Only instead of Content-Security-Policy.
report_uristring""Appended to the policy as ; report-uri <uri> when set.
dynamic_routesmap{}Per-route CSP overrides keyed by URL path. Exact key match wins, then longest matching prefix.

sri

Subresource Integrity validation. When enforce is true, sub-resource responses must include valid integrity hashes using one of the configured algorithms.

policies:
  - type: sri
    enforce: true
    algorithms: [sha256, sha384, sha512]
FieldTypeDefaultDescription
typestringrequiredMust be sri.
enforceboolfalseWhen true, missing or invalid integrity hashes cause the response to be rejected.
algorithmslist[]Accepted integrity hash algorithms (e.g. sha256, sha384, sha512).

assertion

CEL assertion policy. Evaluates a CEL expression and logs/flags when it returns false. Unlike expression, assertions do not block traffic; they are informational only.

policies:
  - type: assertion
    expression: 'response.status_code < 500'
    name: "no-server-errors"
FieldTypeDefaultDescription
expressionstringrequiredCEL expression evaluated for its truth value
namestring"assertion"Human-readable name attached to assertion log entries

Transforms

Transforms modify the response body before it reaches the client. They are specified as a list under transforms and run in order. Reach for transforms when you need to reshape API responses for different consumers.

SBproxy supports twenty-five transform types: json, json_projection, json_schema, template, replace_strings, normalize, encoding, format_convert, payload_limit, discard, sse_chunking, html, optimize_html, html_to_markdown, markdown, css, lua_json, javascript, js_json, wasm, boilerplate, citation_block, json_envelope, cel, a2a_agent_card_rewrite, plus a noop for testing.

json

Reshape JSON responses by setting or merging fields.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    transforms:
      - type: json
        # Field-level edits handled by this transform.

For include/exclude projection, use json_projection. The field list is flat on the transform itself, under fields: (alias include:); there is no nested projection: key, and a config using one fails at load with missing field 'fields'.

transforms:
  - type: json_projection
    fields: [id, name, email, role]

Or to remove sensitive fields, list them under fields: and set exclude: true:

transforms:
  - type: json_projection
    fields: [password, ssn, internal_notes]
    exclude: true

The field reference for this transform is in the json_projection section below.

html

Modify HTML responses by removing elements, injecting content at known positions, and rewriting attributes.

transforms:
  - type: html
    remove_selectors: [script, "#banner"]
    inject:
      - position: head_end
        content: '<link rel="stylesheet" href="https://cdn.example.com/override.css">'
      - position: body_start
        content: '<div id="banner">Maintenance scheduled for tonight</div>'
      - position: body_end
        content: '<script src="https://cdn.example.com/analytics.js"></script>'
    rewrite_attributes:
      - selector: img
        attribute: loading
        value: lazy
    format_options:
      strip_comments: true
      strip_space: true
      lowercase_tags: false

position accepts head_end, body_start, or body_end. Each inject entry is {position, content}.

css

Modify CSS responses by injecting rules, removing rule blocks for specific selectors, and minifying.

transforms:
  - type: css
    inject:
      - "body { background: #fafafa; }"
    remove_selectors: [".legacy-banner"]
    minify: true

Common transform fields

Every entry in the transforms: list is wrapped with these pipeline-level fields, parsed by TransformConfig:

FieldTypeDefaultDescription
typestringrequiredTransform type discriminator (e.g. json, template).
content_typeslist[]Content-Type substrings the transform applies to. Empty matches all.
fail_on_errorboolfalseWhen true, an error in this transform fails the whole response.
max_body_sizeint10485760Maximum body size, in bytes, that this transform will buffer. Larger bodies skip the transform.
disabledboolfalseWhen true, the transform is parsed but not applied.

Type-specific fields are listed below.

json (field manipulation)

Reshape JSON by setting, removing, and renaming fields.

FieldTypeDefaultDescription
setmap{}Fields to set or overwrite. Values may be any JSON.
removelist[]Field names to delete.
renamemap{}old_name -> new_name mapping. Renames happen before set.

json_projection

FieldTypeDefaultDescription
fieldslistrequiredField names to keep (default) or drop (when exclude is true). Alias: include.
excludeboolfalseWhen true, drop the listed fields instead of keeping them.

json_schema

Validate the response body against a JSON Schema document. Schemas are compiled at config-load time. Remote $ref resolution is disabled to prevent SSRF.

FieldTypeDefaultDescription
schemaobjectrequiredThe JSON Schema document.

template

Render the JSON body as input to a minijinja template.

FieldTypeDefaultDescription
templatestringrequiredTemplate source with {{ variable }} syntax.

replace_strings

Apply a list of literal or regex find-and-replace rules to the body.

- type: replace_strings
  replacements:
    - find: "internal.example.com"
      replace: "public.example.com"
    - find: '\d{16}'
      replace: "[REDACTED]"
      regex: true
FieldTypeDefaultDescription
replacementslistrequiredOrdered list of replacement rules.
replacements[].findstringrequiredLiteral substring or regex pattern.
replacements[].replacestringrequiredReplacement string.
replacements[].regexboolfalseWhen true, treat find as a regex.

normalize

Whitespace and newline normalization.

FieldTypeDefaultDescription
trimboolfalseTrim leading and trailing whitespace.
collapse_whitespaceboolfalseCollapse runs of spaces and tabs into a single space.
normalize_newlinesboolfalseReplace \r\n with \n.

encoding

Base64 or URL encode/decode the body.

FieldTypeDefaultDescription
encodingstringrequiredOne of base64_encode, base64_decode, url_encode, url_decode.

format_convert

Convert between JSON and YAML.

FieldTypeDefaultDescription
fromstringrequiredSource format: json or yaml.
tostringrequiredTarget format: json or yaml.

payload_limit

FieldTypeDefaultDescription
max_sizeintrequiredMaximum allowed body size in bytes.
truncateboolfalseWhen true, truncate to max_size. When false, error on oversize.

discard

Drop the response body entirely. Takes no fields.

- type: discard

sse_chunking

Format the body as Server-Sent Events with the configured prefix and double-newline delimiters.

FieldTypeDefaultDescription
line_prefixstring"data: "Prefix prepended to each non-empty line.

optimize_html

Minify HTML by removing comments and collapsing whitespace.

FieldTypeDefaultDescription
remove_commentsbooltrueStrip <!-- ... --> comments.
collapse_whitespacebooltrueCollapse runs of whitespace into a single space (preserves <pre> and <code> content).
remove_optional_tagsboolfalseRemove optional closing tags such as </li>, </p>, </tr> (experimental).

html_to_markdown

FieldTypeDefaultDescription
heading_stylestring"atx"Heading style: atx (uses #), setext (underline).

markdown

Convert Markdown to HTML using pulldown-cmark.

FieldTypeDefaultDescription
smart_punctuationboolfalseEnable smart punctuation (curly quotes, dashes).
tablesboolfalseEnable GitHub-flavored tables.
strikethroughboolfalseEnable ~~strikethrough~~.

Scripting transforms

lua_json runs a Lua script against a parsed JSON body. javascript and js_json run JavaScript. Each is documented in scripting.md. Replace any type: lua references in older configs with type: lua_json.

TypeFieldDefaultDescription
lua_jsonscriptrequiredLua source. The Go-format function name is modify_json(data, ctx); legacy scripts may use a body global. Alias: lua_script.
javascriptscriptrequiredJavaScript source.
javascriptfunction_nametransformEntrypoint function name. Receives the body as a string and ctx as the second argument.
js_jsonscriptrequiredJavaScript source. Alias: js_script.
js_jsonfunction_namemodify_jsonEntrypoint function name. Receives the parsed JSON body and ctx as the second argument.

Lua and JavaScript transform contexts include ctx.request.aipref.train, ctx.request.aipref.search, and ctx.request.aipref.ai_input. Missing or malformed aipref headers leave all three values at true.


Request modifiers

A request gaining an injected header and a rewritten path before the upstream sees it

(config)

Request modifiers run before the action and edit the request. Each entry is an object with one or more of headers, url, query, method, body, lua_script, or js_script. Multiple entries are applied in order.

Header / URL / query / method / body

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    request_modifiers:
      - headers:
          set:
            X-Source: sbproxy
          add:
            X-Trace-Id: "{{ request.headers.x_request_id }}"
          remove:
            - X-Internal-Token
        url:
          path:
            replace:
              old: /old/
              new: /new/
        query:
          set:
            tenant: prod
          add:
            extra: "1"
          remove:
            - debug
        method: POST
        body:
          replace_json:
            injected: true
            source: proxy
FieldTypeDescription
headers.setmapReplace headers (overwrites existing)
headers.addmapAppend headers (preserves existing)
headers.removelistRemove headers (alias: delete)
url.path.replace.oldstringSubstring to find in the request path
url.path.replace.newstringReplacement string
query.setmapReplace query parameters
query.addmapAppend query parameters
query.removelistRemove query parameters (alias: delete)
methodstringOverride the HTTP method
body.replacestringReplace the body with this string
body.replace_jsonobjectReplace the body with this JSON value

Scripted request modifiers

Each modifier entry can supply a lua_script or js_script instead of (or in addition to) the structured fields above. Scripts run with full access to the request context. See scripting.md for the script API.

request_modifiers:
  - lua_script: |
      local access_level = "guest"
      if ip.in_cidr(request_ip, "10.0.1.0/24") then
        access_level = "admin"
      end
      request.headers["X-Access-Level"] = access_level
      return request
request_modifiers:
  - js_script: |
      function modify_request(req, ctx) {
        req.headers["X-Injected"] = "from-js";
        return req;
      }

Response modifiers

An upstream response with headers rewritten and a body substitution applied on the way out

(config)

Response modifiers run after the action and edit the response. Each entry is an object with one or more of headers, status, body, lua_script, or js_script. Multiple entries are applied in order.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    response_modifiers:
      - headers:
          set:
            X-Content-Type-Options: nosniff
            X-Frame-Options: DENY
          remove:
            - Server
            - X-Powered-By
        status:
          code: 200
          text: OK
        body:
          replace: '{"ok": true}'
FieldTypeDescription
headers.setmapReplace headers
headers.addmapAppend headers
headers.removelistRemove headers (alias: delete)
status.codeintOverride the response status code
status.textstringOptional reason phrase (informational only; not sent in HTTP/2)
body.replacestringReplace the response body with this string
body.replace_jsonobjectReplace the response body with this JSON value

For JSON-field-level edits (set fields, delete fields, etc.), use the json transform rather than a response modifier.

Scripted response modifiers

response_modifiers:
  - lua_script: |
      if location.country_code ~= "US" and location.country_code ~= "CA" then
        response.status_code = 451
        response.body = '{"error": "Content not available in your region"}'
      end
      return response
response_modifiers:
  - js_script: |
      function modify_response(res, ctx) {
        res.headers["X-Injected"] = "from-js";
        return res;
      }

Response cache

Cache responses at the origin level to reduce backend load and improve response times for cacheable content. The response_cache block is a sibling of action.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    response_cache:
      enabled: true
      ttl_secs: 300
      cacheable_methods: [GET, HEAD]
      cacheable_status: [200, 301]
      max_size: 10000
FieldTypeDefaultDescription
enabledboolfalseEnable response caching
ttl_secsduration300Cache entry TTL. Accepts integers (60) or humanized strings (60s, 5m, 2h30m). Alias: ttl.
cacheable_methodslist[GET]HTTP methods eligible for caching. Alias: methods.
cacheable_statuslist[200]Status codes eligible for caching. Alias: status_codes.
max_sizeint10000Upper bound on the in-memory cache size in entries. Ignored when an L2 Redis backend is attached.

When proxy.l2_cache_settings is configured with driver: redis, response cache entries are stored in the shared backend; the in-memory max_size becomes irrelevant.


Forward rules

Forward rules route specific requests to different origins based on path, header, or other conditions. They are evaluated in order; the first match wins. Common uses: path-based microservice routing and version routing.

Forward rules are deserialized lazily; required fields are enforced when the rule is exercised, not at config-load time.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://default-backend.internal:8080
    forward_rules:
      # Route /api/v2/* to the v2 backend
      - rules:
          - path:
              prefix: /api/v2/
        origin:
          id: v2-backend
          hostname: v2-backend
          workspace_id: example
          version: "2.0.0"
          action:
            type: proxy
            url: https://v2-backend.internal:8080

      # Route /health to a static response
      - rules:
          - path:
              exact: /health
        origin:
          id: health
          hostname: health
          workspace_id: example
          version: "1.0.0"
          action:
            type: static
            status: 200
            content_type: application/json
            json_body:
              status: healthy

      # Route clients that identify as mobile to the mobile backend
      - rules:
          - header:
              name: X-Client-Platform
              prefix: mobile
        origin:
          id: mobile-backend
          hostname: mobile-backend
          workspace_id: example
          version: "1.0.0"
          action:
            type: proxy
            url: https://mobile-backend.internal:8080

Rule matching

Each forward rule has a rules array where each entry is a matcher. The deserializer accepts these forms only:

FieldTypeDescription
path.prefixstringPath starts with this value.
path.exactstringPath matches this value exactly.
path.templatestringOpenAPI-style path template with named segments, e.g. /users/{id}/posts/{post_id}. Supports catch-all (/static/{*rest}) and per-segment regex constraints (/users/{id:[0-9]+}). Captured params surface on the request context as path_params.
path.regexstringWhole-path regex escape hatch. Named captures ((?P<id>...)) surface params on the request context.
matchstringShorthand. Equivalent to path: { prefix: <value> }.
headerobjectHeader matcher: {name, value} for an exact match or {name, prefix} for a value-prefix match. When both are set, value wins. Header names compare case-insensitively; values case-sensitively.
queryobjectQuery parameter matcher: {name, value} for an exact match, or {name} alone to match on presence.

Set exactly one of prefix, exact, template, or regex on a path matcher. If more than one is set, precedence is template > regex > exact > prefix (so exact beats prefix).

When a rule has multiple matcher entries, the rule fires when any one of them matches. Any other key on a matcher entry (Go-era fields such as methods, ip, location, user_agent, content_types, protocol) is rejected at config load as an unknown key.

Forward rule fields

The forward rule itself wraps the matcher list and the inline child origin to dispatch to.

FieldTypeDefaultDescription
ruleslist[]Matcher entries. The rule fires when any one matches.
originobjectrequiredInline child origin. See below.

The origin object is a full child origin config plus identifying metadata:

FieldTypeDefaultDescription
idstringIdentifier surfaced in metrics and logs.
hostnamestringInformational hostname tag. The parent origin's hostname is what routed the request.
workspace_idstringWorkspace identifier.
versionstringVersion label.
actionobjectrequiredAction executed when the rule fires. Same schema as a top-level action.
request_modifierslist[]Request modifiers applied before the action runs.

Inline origins

Forward rules embed full origin configurations via the origin field. Each inline origin can have its own action, authentication, policies, and transforms, exactly like a top-level origin.

forward_rules:
  - rules:
      - path:
          prefix: /admin/
    origin:
      id: admin
      hostname: admin
      workspace_id: example
      version: "1.0.0"
      action:
        type: proxy
        url: https://admin-backend.internal:8080
      authentication:
        type: basic_auth
        users:
          - username: admin
            password: ${ADMIN_PASSWORD}
      policies:
        - type: rate_limiting
          requests_per_minute: 30

Fallback origin

When the primary action errors or the upstream returns a configured status code, the proxy can swap in a backup origin. The fallback runs the action you'd normally write at the top level (static, redirect, mock, proxy, anything), so you can serve a cached body, redirect to a status page, or route to a degraded backend.

origins:
  "api.local":
    action:
      type: proxy
      url: https://primary-backend:8080

    fallback_origin:
      on_error: true
      on_status: [502, 503, 504]
      add_debug_header: true
      origin:
        id: degraded-stub
        action:
          type: static
          status: 200
          content_type: application/json
          json_body:
            status: degraded
            message: primary upstream temporarily unavailable
            retry_after_secs: 30

Trigger fields

FieldTypeDefaultDescription
on_errorboolfalseTrigger the fallback on transport-level upstream failures (DNS, connect, TLS, timeout).
on_statuslist[int][]Trigger the fallback when the upstream responds with one of these status codes. Pair with on_error for full coverage.
add_debug_headerboolfalseWhen true, the proxy sets X-Fallback-Trigger on the response so callers can tell the fallback path served the request.
originobjectrequiredInline origin spec used to serve the request when a trigger fires. Must contain an action block; id, hostname, workspace_id, and version are accepted as optional metadata.

Inline origin

The origin: field carries the same action types as a top-level origin (proxy, static, redirect, mock, echo, beacon, noop, ai_proxy, load_balancer, websocket, grpc). Authentication, policies, and transforms are not applied to the fallback path; only the action runs. If you need richer behaviour from the fallback, point its action at another origin via proxy and let the host router apply that origin's full chain.


Variables, vaults, and secrets

Variables

User-defined key-value pairs available in template context as {{ variables.name }}. Any JSON type works, including nested objects.

origins:
  "api.example.com":
    variables:
      api_version: v2
      base_url: https://api.example.com
      feature_flags:
        new_ui: true
        beta_api: false
    action:
      type: proxy
      url: "{{ variables.base_url }}/{{ variables.api_version }}"

Secret references

Secrets are resolved through the top-level proxy.secrets block (see Secrets). Once resolved, secrets are available in templates as {{ secrets.name }}.

proxy:
  secrets:
    backend: hashicorp
    hashicorp:
      addr: https://vault.example.com:8200
    map:
      database_url: secret/data/prod/db_url
      stripe_key: secret/data/prod/stripe_key

origins:
  "api.example.com":
    action:
      type: proxy
      url: "{{ secrets.database_url }}"

Template scopes

Templates have access to these scopes:

ScopeDescriptionExample
requestCurrent HTTP request{{ request.headers.x_api_key }}
variablesUser-defined variables{{ variables.api_version }}
secretsLoaded secrets{{ secrets.api_token }}
configConfig metadata{{ config.hostname }}
sessionSession data{{ session.auth.email }}
envConfig identity fields{{ env.workspace_id }}
serverServer-level vars{{ server.var_name }}

Session config

Configure session behavior for an origin. Sessions are stored in encrypted cookies.

origins:
  "app.example.com":
    session:
      cookie_name: sb_session
      max_age: 3600
      same_site: Strict
      http_only: true
      secure: true
      allow_non_ssl: false
FieldTypeDefaultDescription
cookie_namestringSession cookie name
max_ageintCookie lifetime in seconds. Alias: cookie_max_age.
http_onlyboolfalseSet the HttpOnly cookie attribute
secureboolfalseSet the Secure cookie attribute (HTTPS only)
same_sitestringSameSite attribute (Strict, Lax, None). Alias: cookie_same_site.
allow_non_sslboolfalseAllow sessions over plain HTTP

Sessions disable themselves implicitly when the block is omitted.


Compression

Configure response compression on a per-origin basis.

origins:
  "api.example.com":
    compression:
      enabled: true
      algorithms: [br, gzip]
      min_size: 512
FieldTypeDefaultDescription
enabledbooltrueMaster switch. Alias: enable.
algorithmslistAllowed algorithms in priority order (e.g. ["br", "gzip"])
min_sizeint0Minimum response size in bytes before compression is applied
levelintGo-compat compression level. Not used by the Rust runtime.

HSTS

Inject the Strict-Transport-Security header on responses.

origins:
  "secure.example.com":
    hsts:
      max_age: 31536000
      include_subdomains: true
      preload: true
FieldTypeDefaultDescription
max_ageint31536000max-age directive in seconds
include_subdomainsboolfalseEmit the includeSubDomains directive
preloadboolfalseEmit the preload directive

Connection pool

Per-origin connection pool tuning. When unset, falls back to proxy-wide defaults.

origins:
  "api.example.com":
    connection_pool:
      max_connections: 128
      idle_timeout_secs: 90
      max_lifetime_secs: 300
FieldTypeDefaultDescription
max_connectionsint128Maximum concurrent connections to the upstream
idle_timeout_secsint90Maximum idle time before a connection is closed
max_lifetime_secsint300Maximum total lifetime of a connection

Bot detection

Bot detection blocks requests based on User-Agent substring matches. The deny list rejects user agents that contain any of the listed substrings (case-insensitive). The allow list exempts user agents from the deny check, so trusted crawlers can pass through even when their substring is otherwise denied.

origins:
  "api.example.com":
    bot_detection:
      enabled: true
      mode: block
      deny_list:
        - badbot
        - scrapy
        - python-requests
      allow_list:
        - Googlebot
        - bingbot
FieldTypeDefaultDescription
enabledboolfalseMaster switch. When false, every request is admitted.
modestringMode hint (block, log). Currently informational; the runtime always blocks denied agents.
deny_listlist[]User-Agent substrings (case-insensitive) that are blocked with 403.
allow_listlist[]User-Agent substrings (case-insensitive) that bypass the deny check. Evaluated before the deny list.

Threat protection

Threat protection guards against pathological JSON request bodies. When the request Content-Type is application/json, the proxy parses the body and checks it against limits on nesting depth, key count, string length, array size, and total body size. A request that exceeds any limit is rejected before it reaches the upstream.

origins:
  "api.example.com":
    threat_protection:
      enabled: true
      json:
        max_depth: 32
        max_keys: 1000
        max_string_length: 65536
        max_array_size: 10000
        max_total_size: 1048576
FieldTypeDefaultDescription
enabledboolfalseMaster switch for threat checks on this origin.
jsonobjectJSON-specific limits applied when the body is application/json. Omitting this block disables JSON checks even when enabled is true.
json.max_depthintunlimitedMaximum nesting depth across objects and arrays.
json.max_keysintunlimitedMaximum number of keys in any single object.
json.max_string_lengthintunlimitedMaximum length of any single string value.
json.max_array_sizeintunlimitedMaximum length of any single array.
json.max_total_sizeintunlimitedMaximum total body size in bytes, checked before parsing.

Error pages

Error pages let you replace upstream error responses with operator-defined bodies. Each entry declares the status codes it covers, the Content-Type it produces, and the response body. When more than one entry matches the status code, the proxy performs Accept header content negotiation across the candidates and picks the highest-quality match. With no concrete preference it prefers application/json, then text/html, then the first candidate.

The block is a list at the origin level. Each entry's status field accepts a single integer or a list of integers. When template is true, the body is rendered with {{ status_code }} and {{ request.path }} substituted at request time.

origins:
  "api.example.com":
    error_pages:
      - status: [502, 503, 504]
        content_type: text/html; charset=utf-8
        template: true
        body: |
          <h1>Service unavailable</h1>
          <p>Status {{ status_code }} on {{ request.path }}.</p>
      - status: [502, 503, 504]
        content_type: application/json
        template: true
        body: '{"error":"upstream_unavailable","status":{{ status_code }},"path":"{{ request.path }}"}'
      - status: 404
        content_type: application/json
        body: '{"error":"not_found"}'
FieldTypeDefaultDescription
statusint or listStatus code or list of status codes this entry covers. Required for the entry to match.
content_typestringapplication/jsonContent-Type header sent with the response.
bodystring""Response body. May contain template placeholders when template is true.
templateboolfalseWhen true, substitute {{ status_code }} and {{ request.path }} in the body. Both spaced and unspaced forms are accepted.

Problem details (RFC 9457)

The problem_details block opts the origin into RFC 9457 application/problem+json responses for proxy-generated errors that are not matched by an error_pages entry. The two blocks compose: per-status custom pages still win when authored; problem_details catches everything else with a structured body.

origins:
  "api.example.com":
    error_pages:
      - status: 401
        content_type: application/json
        body: '{"error":"unauthorized","hint":"set X-Api-Key"}'

    problem_details:
      enabled: true
      type_base_uri: "https://api.example.com/errors"
      include_detail: true

A proxy-generated 403 on this origin (no error_pages entry) renders as:

{
  "type": "https://api.example.com/errors/403",
  "title": "Forbidden",
  "status": 403,
  "detail": "policy denied",
  "instance": "/restricted"
}
FieldTypeDefaultDescription
enabledboolfalseWhen true, render unmatched proxy-generated errors as application/problem+json.
type_base_uristringBase URI for the type field; the status code is appended (e.g. https://api.example.com/errors/503). When unset the renderer emits the RFC 9457 default about:blank.
include_detailbooltrueWhen false, the detail field is suppressed (operators can avoid leaking internal error text).

The renderer fires from the same proxy-generated error path that error_pages participates in (authentication denials, policy denials, default 404). Upstream-returned status codes are not rewritten; the renderer only handles errors the proxy itself generates.

See examples/problem-details/.

Spec: https://www.rfc-editor.org/rfc/rfc9457.html.

The renderer covers both error sources:

  • Proxy-generated errors (authentication denials, policy denials, the default 404 for unknown origins) when no matching error_pages entry exists.
  • Upstream failures (connect refused, connect timeout, TLS handshake errors, mid-stream connection loss) routed through Pingora's fail_to_proxy path. The detail field carries the RFC 9209 error token (connection_refused, connection_timeout, tls_protocol_error, connection_terminated, http_request_error) so downstream tooling can break down by failure mode without scraping the body.

Idempotency

The idempotency: block opts the origin into RFC 8594-style cached retries. The middleware reads the Idempotency-Key request header, hashes the request body, and:

  • First call under a given key: forwards the request upstream and caches the response under (workspace, key) keyed by the body hash.
  • Replay with the same key + same body: returns the cached response with x-sbproxy-idempotency: HIT. The upstream is not contacted.
  • Conflict (same key, different body): returns 409 with the ledger.idempotency_conflict JSON body per the RFC.

The middleware runs ahead of policy enforcement so a cached replay does not consume a rate-limit slot.

origins:
  "api.example.com":
    idempotency:
      enabled: true
      header_name: Idempotency-Key  # default
      ttl_secs: 86400               # default (24 h)
      methods: [POST, PUT, PATCH]   # default
      backend: memory               # or `redis`
FieldTypeDefaultDescription
enabledboolfalseWhen true, the middleware engages on this origin.
header_namestringIdempotency-KeyRequest header carrying the key.
ttl_secsint86400Cache entry TTL in seconds.
methodslist[POST, PUT, PATCH]HTTP methods that engage the middleware. Other methods pass through.
backendenummemorymemory (per-origin, per-replica) or redis (binds to proxy.l2_cache_settings, alias l2_cache, for cluster-wide replay).
max_request_body_bytesint1048576 (1 MiB)Per-request cap on buffered body bytes. Bodies larger than this skip the cache; response carries x-sbproxy-idempotency: SKIPPED-OVERSIZE-REQUEST.
max_response_body_bytesint1048576 (1 MiB)Per-response cap on cached body bytes. Responses larger than this stream through uncached.
max_concurrent_buffersint256Per-origin cap on concurrent buffered requests. When the pool is exhausted, new requests skip the cache; response carries x-sbproxy-idempotency: SKIPPED-POOL-FULL. Worst-case memory per origin is roughly max_concurrent_buffers * max_request_body_bytes.

The memory backend is per-origin and per-replica: suitable for single-instance deployments and clusters with sticky routing. The redis backend binds at config-compile time to the cluster L2 store configured under proxy.l2_cache_settings (alias l2_cache); an origin asking for redis without that block surfaces a clear config-load error rather than silently downgrading.

See examples/idempotency/.

Spec: https://www.rfc-editor.org/rfc/rfc8594.html.

AI gateway note. The AI proxy path (action: ai_proxy) engages the same middleware: when the origin has an idempotency: block, AI requests get the same cached-replay, conflict, and skip semantics as plain proxy traffic.


Rate limit headers

The rate_limit_headers field at the origin level is reserved for future expansion and is not consumed by the open-source binary. To control X-RateLimit-* and Retry-After emission today, configure the headers block on the rate-limiting policy itself.

origins:
  "api.example.com":
    policies:
      - type: rate_limiting
        requests_per_minute: 600
        headers:
          enabled: true
          include_retry_after: true
FieldTypeDefaultDescription
headers.enabledboolfalseWhen true, emit X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset on responses.
headers.include_retry_afterboolfalseWhen true, emit Retry-After on 429 responses.

The origin-level rate_limit_headers block is accepted for forward compatibility but ignored by the OSS runtime.


Message signatures

The message_signatures block configures RFC 9421 HTTP Message Signature verification for an origin. Verification is wired into the request pipeline: with verify: true, every inbound request must carry a Signature-Input + Signature header pair that matches the configured key_id, or it is rejected with 401 Unauthorized and WWW-Authenticate: Signature before any downstream auth provider runs.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    message_signatures:
      verify: true
      algorithm: hmac_sha256
      key_id: proxy-key-1
      key: ${SIGNING_SHARED_SECRET}
      required_components:
        - "@method"
        - "@target-uri"
        - content-digest
      clock_skew_seconds: 30
FieldTypeDefaultDescription
verifyboolfalseWhen true, enforce signature verification on inbound requests to this origin.
algorithmstringrequiredSignature algorithm: hmac_sha256 or ed25519. An unrecognized value rejects all requests to the origin rather than silently bypassing the gate.
key_idstringrequiredThe keyid value the signer is expected to advertise in Signature-Input.
keystringrequiredVerification key material. For hmac_sha256, the shared secret; for ed25519, the hex- or base64-encoded raw 32-byte public key.
required_componentslist[]Canonical components every accepted signature must cover, e.g. @method, @target-uri, content-digest. A signature covering a strict subset is rejected.
clock_skew_secondsint30Tolerance applied to the signature's created / expires timestamps.

Traffic capture

The traffic_capture block is reserved for request mirroring and capture configuration. There is no consumer for it in the open-source binary. The field is accepted on the origin so configs that target a future release or an external capture hook validate without errors. Set the block only when an out-of-tree component reads it.

For shadow traffic that is wired into the OSS request path, use mirror instead.


Host header semantics

A request whose Host header is rewritten before it reaches the upstream, shown by the echoed request

(config)

When the proxy forwards a request to an upstream, it controls the upstream Host header explicitly:

  1. The default is the upstream URL's hostname. So url: https://api.upstream.com:8443 causes the upstream to see Host: api.upstream.com:8443. This works correctly with vhost-routed services like Vercel, Cloudflare-fronted origins, S3 website endpoints, and AWS ALBs out of the box.
  2. If the action sets host_override: <value>, that value wins.
  3. If a request modifier sets Host, the modifier takes precedence over both above (it runs after the proxy's default).

Whenever the proxy rewrites Host (i.e. the upstream value differs from what the client sent), it also sets X-Forwarded-Host: <client's original Host> so the upstream can still observe the public name. Suppress that breadcrumb with disable_forwarded_host_header: true.

The same host_override field is accepted on every URL-bearing action: proxy, each load_balancer target, websocket, graphql, a2a, forward_auth, and AI provider entries. grpc exposes the equivalent control as authority, matching the HTTP/2 spec name.


Origin overrides

An origin dialing a fixed IP with a custom SNI while the client-facing hostname stays unchanged

(config)

Three knobs control how the proxy reaches the upstream, all independent so they compose:

FieldWhat it changescurl analogue
host_overrideUpstream Host HTTP header--header "Host: ..."
sni_overrideTLS SNI server name (and cert verification target)--resolve (TLS leg)
resolve_overrideConnect address (skips DNS for the URL host)--connect-to

Common patterns:

Front a SaaS where the cert hostname differs from the URL host.

action:
  type: proxy
  url: https://api.tenant.example.com
  sni_override: cdn.provider.net           # cert is for *.provider.net
  host_override: api.tenant.example.com    # upstream still expects the tenant hostname

Pin a region without polluting the system resolver.

action:
  type: proxy
  url: https://api.example.com
  resolve_override: 203.0.113.7:443        # eu-west-1 anycast

Stage a cutover by pointing at a candidate IP.

action:
  type: proxy
  url: https://api.example.com
  resolve_override: "[2001:db8::1]:8443"

resolve_override accepts ip, ip:port, [ipv6]:port, or host:port. When the port is omitted, the URL's port is used. The proxy still sends the URL's hostname in the request line; only the connect address changes.


Trusted proxies and forwarding headers

X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host arriving at the upstream, then suppressed via the disable flags

(config)

The client IP resolved from X-Forwarded-For only when the peer is a trusted proxy

(config)

When SBproxy is itself behind another load balancer or CDN (Cloudflare, AWS ALB, Fly.io, internal LB), the immediate TCP peer is that LB, not the real client. To recover the real client identity safely, configure proxy.trusted_proxies with the source ranges of those upstream hops:

proxy:
  trusted_proxies:
    - 10.0.0.0/8
    - 2001:db8::/32        # IPv6 supported

Behaviour:

  • If the immediate TCP peer falls inside any trusted CIDR, the proxy parses the inbound X-Forwarded-For chain and uses the leftmost untrusted hop as the real client IP. This becomes ctx.client_ip for the rest of the request: rate limits, IP filters, audit logs.
  • If the immediate TCP peer is not trusted, every inbound forwarding header is stripped on ingress. A direct client cannot spoof its source identity by setting X-Forwarded-For: 1.2.3.4.

The proxy then sets the standard forwarding headers on every upstream request:

HeaderSet toOpt-out flag
X-Forwarded-Hostclient's original Host (when proxy rewrites Host)disable_forwarded_host_header
X-Forwarded-Forclient IP appended to existing chaindisable_forwarded_for_header
X-Real-IPthe immediate client IPdisable_real_ip_header
X-Forwarded-Protohttps if the listener was TLS, else httpdisable_forwarded_proto_header
X-Forwarded-Portthe listener portdisable_forwarded_port_header
Forwarded (RFC 7239)for=<client>; proto=<scheme>; host=<orig>; by=<proxy> (IPv6 bracketed per RFC)disable_forwarded_header
Viaappended 1.1 sbproxydisable_via_header

All flags live on the action (or per-target on a load balancer). Default is enabled (no flag set). See example 73 and example 74.


Request mirror

Production traffic answered by the primary while a copy of each request arrives at the mirror upstream

(config)

Send a fire-and-forget copy of every matched request to a shadow upstream. The mirror response is read and discarded; the client only ever sees the primary's response. Useful for safe rollouts of new backends, replay-style testing, and capturing production traffic patterns without affecting end-users.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://primary.internal:8080
    mirror:
      url: https://shadow.internal:8080
      sample_rate: 0.1       # mirror ~10% of requests; default 1.0
      timeout_ms: 5000       # mirror request timeout; default 5000
FieldTypeDefaultDescription
urlstringrequiredMirror upstream URL. IPv6 hosts must be bracketed (http://[2001:db8::1]:8080).
sample_ratefloat1.0Probability in [0.0, 1.0] that a given request is mirrored.
timeout_msint5000Per-mirror request timeout. Independent of the primary upstream timeout.
mirror_bodyboolfalseTee the inbound request body into the mirror request. Off by default, mirror sees only method, path, query, and headers (sufficient for read endpoints; safe for any case where shadow-replaying writes is unsafe). Set true to shadow-replay POST/PUT/PATCH endpoints during migrations.
max_body_bytesint1048576Body size cap (bytes). Bodies larger than this fire the mirror without a body so a single large upload can't blow up proxy memory. Defaults to 1 MiB.

Mirror requests carry X-Sbproxy-Mirror: 1 and the original X-Sbproxy-Request-Id so the shadow upstream can distinguish them from real traffic. Method, path/query, and headers are mirrored. Request bodies are mirrored only when mirror_body: true; bodies larger than max_body_bytes fire the mirror without a body so large uploads do not grow proxy memory unbounded. Hop-by-hop headers and Host are not forwarded, and reqwest rebuilds Host from the mirror URL.

See example 75.


Upstream retries

When an upstream connection fails (TCP refused, DNS failure, TLS handshake error, or connect timeout), or when an upstream response returns a configured status code, the proxy can retry the request automatically.

origins:
  "api.example.com":
    action:
      type: proxy
      url: http://backend.internal:8080
      retry:
        max_attempts: 3
        retry_on:
          - connect_error
          - timeout
          - 502
          - 503
        backoff_ms: 100
FieldTypeDefaultDescription
max_attemptsint1Total request attempts including the original. 1 disables retries.
retry_onarray[connect_error, timeout]Retry conditions. Recognized values include connect_error, timeout, and numeric upstream status codes such as 502 or 503. Status codes may be written as YAML numbers or strings.
backoff_msint100Base backoff before the next attempt. Doubles on each retry, capped at 5000ms.

retry is accepted on both proxy and load_balancer actions. For load_balancer, a failed target is reported to the outlier detector and circuit breaker so the next retry attempt selects a different healthy peer rather than retrying the same dead target.

Status-code retries are decided after upstream response headers arrive and before any downstream response headers are written. The proxy only replays methods that are safe or idempotent by HTTP semantics: GET, HEAD, OPTIONS, TRACE, PUT, and DELETE. A request with a body is replayed only after the downstream body has fully arrived and Pingora's retry buffer still contains the full body. Non-idempotent methods such as POST and PATCH, still-streaming bodies, and bodies larger than the retry buffer pass through unchanged. When a configured status retry is skipped, the response carries x-sbproxy-retry-skip-reason with one of non_idempotent_method, streaming_body, body_too_large, body_unavailable, or max_attempts_exhausted.

See example 76.


Active health checks

Configure background probes per load_balancer target. The proxy GETs the probe URL on a fixed interval and tracks consecutive success / failure counts. Targets that fail the threshold are excluded from select_target until they recover. Probe results also feed the outlier detector when one is configured, so passive and active signals share state.

action:
  type: load_balancer
  targets:
    - url: http://backend-1.internal:8080
      health_check:
        path: /healthz
        interval_secs: 10        # probe period in seconds
        timeout_ms: 2000
        unhealthy_threshold: 3
        healthy_threshold: 2
    - url: http://[2001:db8::1]:8080
      health_check:
        path: /healthz
FieldTypeDefaultDescription
pathstring/healthzPath to probe. Must start with /.
interval_secsint10Probe period in seconds (alias: period_secs).
timeout_msint2000Per-probe timeout.
unhealthy_thresholdint3Consecutive failures required to mark unhealthy.
healthy_thresholdint2Consecutive successes required to recover.

IPv6 targets are supported: the URL builder preserves bracketing. See example 77.


Circuit breaker

A formal Closed โ†’ Open โ†’ HalfOpen โ†’ Closed state machine attached to each load_balancer target. On failure_threshold consecutive failures (5xx response, connect error, timeout) the breaker trips Open; every subsequent request to that target is excluded from select_target and routed to a healthy peer instead. After open_duration_secs, the breaker enters HalfOpen and admits probe requests; on success_threshold consecutive successes it closes again, otherwise it re-opens.

action:
  type: load_balancer
  circuit_breaker:
    failure_threshold: 5         # trip after 5 consecutive failures
    success_threshold: 2         # close after 2 consecutive HalfOpen successes
    open_duration_secs: 30       # stay Open for 30s before trying probes
  targets:
    - url: http://backend-1.internal:8080
    - url: http://backend-2.internal:8080
FieldTypeDefaultDescription
failure_thresholdint5Consecutive failures before tripping Open.
success_thresholdint2Consecutive successes in HalfOpen to return to Closed.
open_duration_secsint30How long the breaker stays Open before admitting probes.

The breaker is complementary to outlier detection:

SignalTrigger
Circuit breakerN failures in a row, immediate isolation
Outlier detectionFailure rate over a sliding window

Either signal independently ejects a target from select_target. Configure both for robust resilience: outlier detection catches "this target is bad in aggregate," the breaker catches "this target is hard down right now." When every target is tripped, the LB falls back to the unfiltered list rather than 502'ing the client.

See example 84.


Outlier detection

Track each load_balancer target's success/failure rate over a sliding window and eject targets whose error rate crosses the threshold. Failures are recorded from upstream 5xx responses and from connect errors; recovery happens automatically after the cooldown.

action:
  type: load_balancer
  outlier_detection:
    threshold: 0.5              # 50% error rate
    window_secs: 60             # sliding window length
    min_requests: 5             # minimum requests in window before ejection
    ejection_duration_secs: 30  # cooldown before re-admission
  targets:
    - url: http://backend-1.internal:8080
    - url: http://backend-2.internal:8080
FieldTypeDefaultDescription
thresholdfloat0.5Failure rate at which to eject (0.0-1.0).
window_secsint60Sliding window length in seconds.
min_requestsint5Minimum requests in the window before ejection is considered.
ejection_duration_secsint30How long to keep an ejected target out of rotation.

When all active targets are ejected, the proxy falls back to the unfiltered list rather than 502'ing the client (better to send to a flaky peer than to fail closed). See example 78.


Service discovery

Without service discovery, the proxy resolves an upstream hostname once when a connection is established and the connection pool reuses that connection (and that IP) for as long as the connection lives. When the upstream's IP set changes, K8s Service endpoints rotate, ECS Cloud Map adds a new task, the backend behind a Headless service scales horizontally, the proxy keeps using the stale IP until the connection eventually closes.

service_discovery on a proxy action makes the proxy re-resolve the hostname every refresh_secs and rotate the chosen upstream IP across the current A/AAAA record set.

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.namespace.svc.cluster.local:8080
      service_discovery:
        enabled: true
        refresh_secs: 30        # default
        ipv6: true              # default; drop to false to skip AAAA
FieldTypeDefaultDescription
enabledbooltrueMaster switch. The presence of the block usually means "I want it on"; set false to keep the config without enabling.
refresh_secsint30How often to re-resolve. Setting this below the upstream record's actual TTL has no effect, the system resolver applies its own caching, but the proxy will at least notice changes within refresh_secs of the upstream-side update.
ipv6booltrueWhether AAAA records contribute to the rotation set.

The hostname stays as the SNI / Host header so TLS verification continues to match the certificate that was issued for the hostname. IPv6 resolved addresses are wrapped in brackets ([2001:db8::1]:port) when handed to Pingora. Round-robin selection within the resolved set spreads load across all current IPs.

When DNS resolution fails (network glitch, hostname temporarily NXDOMAIN), the proxy falls back to letting Pingora's connect-time resolver handle the lookup.

See example 83.


Correlation ID

The proxy mints a per-request correlation identifier early in the request lifecycle. With the default policy:

  1. If the inbound request carries X-Request-Id, its value becomes the request's correlation ID. Upstream callers (a frontend, an API client, another proxy) get to thread their traces through ours.
  2. Otherwise the proxy generates a fresh UUID v4 (32 hex chars).
  3. The chosen value is set on the upstream request under the same header name so the upstream sees the same ID the proxy logged.
  4. The chosen value is echoed back to the client on the response, so the client can hand it to support to find the matching server logs.
proxy:
  correlation_id:
    enabled: true              # default
    header: X-Request-Id       # default; rename for shops that use X-Correlation-Id
    echo_response: true        # default; set false to omit the response header
FieldTypeDefaultDescription
enabledbooltrueMaster switch.
headerstringX-Request-IdHeader name read on ingress, set on the upstream, and echoed on the response.
echo_responsebooltrueWhether to set the header on the downstream response.

The same value is exposed as ctx.request_id to every other component: webhook envelopes (X-Sbproxy-Request-Id), access logs, alert webhooks, and the AI gateway's per-call records. Set enabled: false to opt out entirely.

Inbound values longer than 256 characters are ignored (the proxy generates a fresh ID). Empty / whitespace-only inbound values are ignored.

See example 80.


mTLS client authentication

When set, the HTTPS listener requires (or optionally accepts) a client TLS certificate signed by the configured CA bundle. The verification happens during the TLS handshake, clients without a valid cert are rejected before request_filter ever runs.

proxy:
  http_bind_port: 8080
  https_bind_port: 8443
  tls_cert_file: /etc/ssl/sbproxy/server.pem
  tls_key_file: /etc/ssl/sbproxy/server.key
  mtls:
    client_ca_file: /etc/ssl/sbproxy/clients-ca.pem
    require: true              # default; set false to allow anonymous TLS clients
FieldTypeDefaultDescription
client_ca_filestringrequiredPEM-encoded CA bundle used to verify client certs. May contain multiple BEGIN CERTIFICATE blocks; each becomes a trust anchor.
requirebooltrueWhen true, the handshake fails if the client does not present a certificate. When false, anonymous clients are admitted and the upstream sees no X-Client-Cert-* headers (so it can choose its own policy).

After a successful handshake, the proxy strips any inbound X-Client-Cert-* headers (so a non-TLS client cannot forge them) and sets the verified cert metadata for the upstream:

HeaderValue
X-Client-Cert-Verified1
X-Client-Cert-CNSubject Common Name, when present
X-Client-Cert-SANComma-separated DNS:/URI:/email:/IP: SANs
X-Client-Cert-OrganizationSubject's O field, when present
X-Client-Cert-Serialhex serial number
X-Client-Cert-Fingerprinthex SHA-256 of the cert

CN and SAN are extracted by a wrapping ClientCertVerifier that captures them at handshake time and indexes by SHA-256 of the cert DER (which matches Pingora's internal cert_digest). Chain validation is unchanged. The cache is bounded so a churning client population does not grow it without bound.

See example 85.


Webhook envelope and signing

Every webhook the proxy fires (on_request, on_response, alerting channels) carries a standard identifying envelope and optional HMAC-SHA256 signature.

Envelope

{
  "event": "on_request",
  "proxy": {
    "instance_id": "sbproxy-host-7c4d8b9a",
    "version": "0.1.0",
    "config_revision": "a7b3f9c11d80"
  },
  "request": {
    "id": "01j9x4af1k73c5dvkk1xvb6f9w",
    "received_at": "2026-04-25T07:32:00Z"
  },
  "origin": { "name": "api.example.com" },
  "method": "GET",
  "path": "/api/users",
  "host": "api.example.com",
  "client_ip": "203.0.113.7",
  "headers": { "...": "..." }
}

on_response payloads include the same proxy.* and request.id fields, plus status and duration_ms, so receivers can correlate the request/response pair.

Headers on the webhook request

HeaderValue
User-Agentsbproxy/<version>
X-Sbproxy-Eventon_request, on_response, or alert
X-Sbproxy-Instanceper-process instance identifier
X-Sbproxy-Request-Idmatches request.id in the envelope
X-Sbproxy-Config-Revisionshort hex hash of the loaded config
X-Sbproxy-Timestampunix seconds at send time
X-Sbproxy-Signaturev1=<hex> (only when secret is configured)

Signing

Set a secret on the callback to enable HMAC-SHA256:

on_request:
  - url: https://hooks.example.com/sbproxy
    method: POST
    secret: shared-webhook-secret
    timeout: 5

The signed material is "<timestamp>.<body>". Receivers should:

  1. Read X-Sbproxy-Timestamp and reject anything older than ~5 minutes (replay defence).
  2. Compute HMAC-SHA256(secret, timestamp + "." + raw_body).
  3. Compare to X-Sbproxy-Signature (v1=<hex>) using a constant-time comparison.

Alert webhook channels (proxy.alerting.channels[]) do not accept a secret field; a channel entry takes only type, url, and headers, and anything else is rejected at config load. Alert-payload signing is not configurable yet. See example 79.


Secrets

The top-level proxy.secrets block configures how secret: references are resolved at config-load time and how rotation is handled.

proxy:
  secrets:
    backend: hashicorp
    hashicorp:
      addr: https://vault.example.com:8200
      token: ${VAULT_TOKEN}
      mount: secret
    map:
      openai_key: secret/data/prod/openai_key
      db_password: secret/data/prod/db_password
    rotation:
      grace_period_secs: 300
      re_resolve_interval_secs: 60
    fallback: cache
FieldTypeDefaultDescription
backendstringenvBackend used to resolve secrets. Supported: env, local, hashicorp.
hashicorp.addrstringVault server address (required when backend = hashicorp)
hashicorp.tokenstringfrom VAULT_TOKEN env varVault token
hashicorp.mountstringsecretKV secrets engine mount path
mapmapLogical-name to vault-path mapping
rotation.grace_period_secsint300Seconds the previous secret value remains valid after rotation
rotation.re_resolve_interval_secsint60How often to re-fetch secrets from the backend
fallbackstringcacheStrategy when the backend is unavailable. Supported: cache, reject, env.

The extensions map at both the proxy and the origin level holds opaque blocks consumed by enterprise / third-party crates. OSS does not parse them.

Secret reference URI schemes

In addition to ${ENV}, file:, and secret:, secret-bearing fields accept provider-specific secret reference URIs. The scheme names the provider type, the authority names the configured backend instance, and the path is interpreted by that provider.

Grammar

<scheme>://<backend-name>/<provider-path>[?version=<n>][&key=<json-field>]
SchemeProvider typeExample
vault://HashiCorp Vault KVvault://primary/secret/data/openai-prod?key=api_key
awssm://AWS Secrets Managerawssm://primary/openai-keys?version=3&key=api_key
gcpsm://GCP Secret Managergcpsm://primary/openai-api-key?version=latest
k8ssecret://Kubernetes Secretk8ssecret://primary/sbproxy-secrets/openai-key
secretfile://Local YAML or JSON secret filesecretfile://local/openai-prod?key=api_key
secret://Local static secret mapsecret://local/openai-prod
  • <backend-name> is the operator-chosen backend instance name declared under proxy.secrets.backends:.
  • <provider-path> is the backend-specific path. The parser carries it verbatim; each backend validates its own shape at resolve time.
  • version=<n> pins a secret version where the backend supports versioning, such as HashiCorp KV v2, AWS Secrets Manager, or GCP Secret Manager. It is ignored by versionless backends.
  • key=<json-field> extracts a sub-field from a JSON secret payload. When omitted the entire payload is returned.
  • Additional query parameters carry through to the backend as opaque hints; the parser does not interpret them.

Examples

authentication:
  type: bearer
  tokens:
    - vault://primary/secret/data/openai-prod?key=api_key
    - awssm://primary/prod/openai-keys?version=3&key=api_key
    - gcpsm://primary/openai-api-key?version=latest
    - k8ssecret://primary/sbproxy-secrets/openai-key
    - secretfile://local/openai-prod?key=api_key
    - secret://local/openai-prod
    - ${OPENAI_API_KEY}

Backward compatibility

Existing ${ENV} and file:/path/to/secret shapes keep working unchanged. The Go-era secret:<name> colon form is removed and fails config load with a pointer at the secret://<backend>/<name> replacement. Legacy umbrella references shaped as vault://<alias>/... are still accepted with a warning as of SBproxy 1.5.0; a removal release has not been announced.

Rewrite known legacy aliases with:

sbproxy config migrate sb.yml --out sb.migrated.yml

Multiple backends

Backends are declared once, at proxy scope, under proxy.secrets.backends:. There is no per-tenant or per-origin backend list; the <backend-name> segment in a reference URI selects the backend by name, and the scheme requires that backend to have the matching provider type. To keep tenants on separate Vault instances, declare one named backend per instance and reference the right name from each origin.

proxy:
  secrets:
    backends:
      - type: hashicorp
        name: acme-vault
        addr: https://vault.acme.example/v1
        auth:
          type: token
          token: ${VAULT_TOKEN_ACME}
      - type: hashicorp
        name: beta-vault
        addr: https://vault.beta.example/v1
        auth:
          type: token
          token: ${VAULT_TOKEN_BETA}
origins:
  api.acme.example.com:
    action:
      type: ai_proxy
      providers:
        - name: openai
          api_key: vault://acme-vault/secret/data/openai-prod?key=api_key
  api.beta.example.com:
    action:
      type: ai_proxy
      providers:
        - name: openai
          api_key: vault://beta-vault/secret/data/openai-prod?key=api_key

The vault://acme-vault/... reference resolves against the acme-vault backend at vault.acme.example; the beta-vault reference resolves against the other instance. Backend types are local, file, hashicorp, aws, gcp, and k8s; see secrets.md for each backend's fields and auth methods. An unresolved reference in a secret-bearing field fails startup rather than reaching the wire verbatim.


Environment variables

Reference environment variables anywhere in the config with ${VAR_NAME} syntax to keep secrets out of config files.

origins:
  "api.example.com":
    action:
      type: proxy
      url: ${BACKEND_URL}
    authentication:
      type: api_key
      api_keys:
        - ${API_KEY}

Environment variables are resolved at config load time. An unset variable leaves the literal ${VAR_NAME} string in place rather than failing the load.

Common pattern: load variables from .env with your shell or Docker:

export BACKEND_URL=https://backend.internal:8080
export API_KEY=my-secret-key
sbproxy serve -f sb.yml

ACME / auto TLS

SBproxy can automatically provision and renew TLS certificates using the ACME protocol (Let's Encrypt or any ACME-compatible CA).

Production setup (Let's Encrypt)

proxy:
  http_bind_port: 80
  https_bind_port: 443
  acme:
    enabled: true
    email: admin@example.com
    storage_path: /var/lib/sbproxy/certs

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    force_ssl: true
FieldTypeDefaultDescription
enabledboolfalseMaster switch for ACME-managed TLS
emailstringAccount contact email registered with the ACME directory
directory_urlstringLet's Encrypt productionACME directory URL
challenge_typeslist[http-01]Allowed challenge types in priority order. Only http-01 is driven today.
storage_backendstringredbWhere issued certs live: redb (local file, default), file, redis, s3, gcs, azure, or memory. See below.
storage_pathstring/var/lib/sbproxy/certsThe store's location: a directory (redb, file), a host:port (redis), or a URL like s3://bucket/prefix (s3/gcs/azure)
renew_before_daysint30Days before expiry to attempt renewal

Certificate store backends

A single node keeps its certificates in a local redb file (the default), so a restart reuses the cert instead of asking the CA for a fresh one. A fleet behind a load balancer needs a shared store, or every node issues its own cert and runs into the CA's rate limits. Point storage_backend at a shared store and the nodes coordinate: whichever one wins a per-hostname issuance lock issues the certificate, and the rest read it back.

Backendstorage_pathUse
redba directorysingle node (default); survives restarts
filea shared directorya fleet on shared storage (NFS/EFS)
redishost:porta fleet with Redis
s3, gcs, azures3://bucket/prefix, gs://bucket/prefix, az://...a fleet on object storage; credentials come from the environment
memoryignoredtests only; nothing persists

The shared backends hold the issuance lock as an atomic create with a lease. A node that crashes mid-issue does not wedge the others: the lease expires and another node takes over.

Local development (Pebble)

Pebble is a test ACME server suitable for local development. Point directory_url at it:

proxy:
  http_bind_port: 8080
  https_bind_port: 8443
  acme:
    enabled: true
    email: test@example.com
    directory_url: https://pebble:14000/dir
    storage_path: /tmp/certs

Redis integration

Redis has two roles in SBproxy: distributed caching (L2 cache) and real-time messaging (config sync, cache invalidation). Both blocks are nested under proxy:.

L2 cache (distributed rate limiting and caching)

proxy:
  l2_cache_settings:
    driver: redis
    params:
      dsn: redis://redis.internal:6379/0

When configured, rate limit counters are shared across all proxy instances. Response cache entries can also be stored in Redis for shared caching. The deserializer also accepts l2_cache: as a canonical alias.

Messenger (real-time config updates)

proxy:
  messenger_settings:
    driver: redis
    params:
      dsn: redis://redis.internal:6379

When configured, config changes pushed via the API propagate to all proxy instances in real time over Redis Streams.

The Redis driver expects params.dsn. SQS uses queue_url, region, api_key. GCP Pub/Sub uses project, topic, subscription, access_token. The memory driver takes no params and is single-replica only.

Full Redis setup

proxy:
  http_bind_port: 8080
  https_bind_port: 8443
  l2_cache_settings:
    driver: redis
    params:
      dsn: redis://redis.internal:6379/0
  messenger_settings:
    driver: redis
    params:
      dsn: redis://redis.internal:6379

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    policies:
      - type: rate_limiting
        requests_per_minute: 100
    response_cache:
      enabled: true
      ttl_secs: 300

Validation

Check the configuration for errors without starting the proxy:

sbproxy validate /etc/sbproxy/sb.yml
# or, equivalently, on a running --config invocation
sbproxy --config /etc/sbproxy/sb.yml --check

This catches:

  • YAML syntax errors
  • Missing required top-level fields
  • Unknown action / policy / transform types

Validate every config change before deploying to production. Metrics are exposed via the embedded admin server: set proxy.admin.enabled: true, proxy.admin.port: 9090, and tune proxy.metrics.max_cardinality_per_label for high-traffic deployments.

For production deployments, the sbproxy plan and sbproxy apply subcommands give a Terraform-style diff-and-confirm path on top of validate: plan -f diffs a proposed config against a baseline (exit 0 no-op, 2 changes present, 3 semantic errors) and apply validates and reloads in place. See manual.md for the full CLI reference.


CORS

Configure Cross-Origin Resource Sharing as a top-level origin field:

origins:
  "api.example.com":
    action:
      type: proxy
      url: https://backend.internal:8080
    cors:
      allowed_origins: ["https://app.example.com", "https://admin.example.com"]
      allowed_methods: [GET, POST, PUT, DELETE, OPTIONS]
      allowed_headers: [Content-Type, Authorization, X-Requested-With]
      expose_headers: [X-Request-ID, X-RateLimit-Remaining]
      max_age: 3600
      allow_credentials: true

The presence of the cors: block is what enables CORS header injection. A legacy enable flag (alias enabled) still parses for backward compatibility, but the runtime never checks it: enable: false does not turn the block off.

FieldTypeDefaultDescription
allowed_originslistAllowed origins (use ["*"] for any). Alias: allow_origins.
allowed_methodsliststandard methodsAllowed HTTP methods. Alias: allow_methods.
allowed_headersliststandard headersAllowed request headers. Alias: allow_headers.
expose_headerslistHeaders exposed to the browser
max_ageintPreflight cache duration in seconds
allow_credentialsboolfalseAllow credentials (cookies, auth headers)
enableboolunsetLegacy flag, alias enabled. Parsed but ignored at runtime.

Quick reference: config field locations

A common mistake is nesting fields inside action when they should be siblings. The correct layout:

origins:
  "api.example.com":
    # These are ALL at the same level (siblings of action):
    action: { ... }
    authentication: { ... }
    policies: [ ... ]
    transforms: [ ... ]
    request_modifiers: [ ... ]
    response_modifiers: [ ... ]
    forward_rules: [ ... ]
    response_cache: { ... }
    variables: { ... }
    session: { ... }
    cors: { ... }
    compression: { ... }
    hsts: { ... }
    connection_pool: { ... }
    mirror: { ... }                # shadow traffic; sibling of action
    on_request: [ ... ]            # webhook callbacks
    on_response: [ ... ]
    extensions: { ... }

None of these belong inside the action block. The action block only contains action-specific fields (type, url, targets, providers, etc.).

A handful of fields do live inside an action because they govern how the proxy talks to that specific upstream:

action:
  type: proxy
  url: https://upstream.example/api
  host_override: api.upstream.example       # rewrite the upstream Host
  disable_via_header: true                  # any of the disable_*_header flags
  retry: { ... }                            # upstream retry policy

load_balancer actions accept an outlier_detection block at the action level and per-target health_check, host_override, and disable_*_header flags inside each target.

Environment variable templating in header modifiers

Request and response header modifiers may reference environment variables using the {{env.NAME}} template form. To prevent multi-tenant exfiltration of process secrets, env expansion is gated by an explicit allowlist on TemplateContext::allowed_env_vars. This change is tracked under OPENSOURCE.md H4.

  • The default allowlist is empty. With the default, every {{env.X}} template resolves to the empty string and a tracing::warn! is logged. This includes well-known secret names like AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, and any custom _TOKEN / _KEY env vars set on the proxy process.
  • Operators opt in per-installation by adding env var names to TemplateContext::allowed_env_vars when populating the per-request template context. Names are matched literally; case matters.
  • Allowlisted env vars that are unset at the OS level resolve to the literal {{env.X}} string so misconfiguration shows up as obviously broken header values rather than silently empty ones.

Example header modifier and the matching allowlist a deployment would use:

request_modifiers:
  - headers:
      set:
        X-Build-Id: "{{env.SBPROXY_BUILD_ID}}"
        X-Region:   "{{env.SBPROXY_REGION}}"
// Inside the proxy runtime that builds TemplateContext per request.
let mut tmpl = sbproxy_middleware::modifiers::TemplateContext::new();
tmpl.allowed_env_vars.push("SBPROXY_BUILD_ID".to_string());
tmpl.allowed_env_vars.push("SBPROXY_REGION".to_string());

A header value of {{env.AWS_SECRET_ACCESS_KEY}} will not resolve unless AWS_SECRET_ACCESS_KEY is added to that allowlist. There is no global "allow all env vars" switch.