Authentication

The chooser across all twelve auth providers: what each proves, when to use it, and how one origin accepts more than one

Every origin decides who may call it with an authentication block, a sibling of action in sb.yml (auth is an accepted alias). SBproxy ships twelve built-in providers, from a static API key to a full OpenID Connect login. This page is the chooser: which provider fits which caller, how to accept more than one on the same origin, and what the rest of the gateway does with the identity a provider establishes. The field-by-field reference for all twelve lives in configuration.md.

Two related things are deliberately absent from the tables below. mTLS client certificates are verified during the TLS handshake, before any auth provider runs, so they are configured on the listener rather than per origin; see what rides alongside. And a type: value that names none of the twelve falls through to the auth plugin registry, so a linked plugin crate can add types such as oauth_introspection or saml without patching the proxy (configuration.md).

The whole decision path, socket to policy chain. Stages the pipeline runs between these boxes (CORS preflight, bot and agent identity resolution, and the rest) are in architecture.md:

flowchart TD
    REQ["Request arrives,\nhostname matched to an origin"] --> TLS{"Listener mTLS\nconfigured?"}
    TLS -->|no| AB{"The origin's\nauthentication block"}
    TLS -->|yes| MV["Client certificate verified\nin the TLS handshake"]
    MV -->|"handshake fails"| DROP["Rejected before\nany provider runs"]
    MV -->|verified| AB
    AB -->|"single provider"| ONE["The one provider\naccepts or rejects"]
    AB -->|"composition list"| SLOT["Try the next entry\nin declared order"]
    SLOT -->|"rejects, entries remain"| SLOT
    SLOT -->|accepts| WIN["The winner binds the principal:\nattribution, access log principal_kind,\ndecision records, the auth metric"]
    SLOT -->|"rejects, list exhausted"| DENY["Denied at the proxy:\nthe first entry's status and message,\nall WWW-Authenticate challenges\nmerged on (RFC 7235)"]
    ONE -->|accepts| WIN
    ONE -->|rejects| DENY
    WIN --> POL["Request policy chain\n(rate limits, WAF, object_authz, ...)"]

Which provider

People at browsers and terminals

Provider What it proves Reach for it when Example
basic_auth The caller knows a username and password pair. A simple internal service or admin panel needs a lock on it. auth-basic
digest The caller knows a password, proven by an RFC 7616 hash exchange that keeps the password off the wire. A legacy system insists on digest auth. SHA-256 is the default; MD5 stays available but you have to ask for it. auth-digest
oidc The caller completed a login at your IdP and holds a sealed session cookie. You want SSO in front of an app that has none, the oauth2-proxy and Cloudflare Access use case. oidc
forward_auth An external HTTP service you run answered the success status for a per-request subrequest. Auth logic already lives in its own service and the gateway should defer to it. auth-forward

Services, agents, and crawlers

Provider What it proves Reach for it when Example
api_key Possession of a static key, sent in a header or an opt-in query parameter. The plainest machine-to-machine handshake is enough. auth-api-key
bearer Possession of a static token, optionally bound to a DPoP proof-of-possession key so a stolen token alone is not enough. Token-based service auth without an issuer in the loop. auth-bearer, auth-bearer-dpop
jwt A token signed by an issuer you trust, with issuer, audience, algorithm, and claim checks. Accepts JWE-encrypted tokens (decrypt, then verify) and can require DPoP or mTLS-bound cnf claims. Callers hold OAuth2/OIDC-issued tokens and the gateway should verify them locally. auth-jwt
hmac_auth The caller holds a shared secret and signed this exact request (RFC 9421, hmac-sha256), so a captured request replays nowhere else. Webhook senders and API clients that should never put a reusable credential on the wire. auth-hmac
ldap_auth The directory accepted a bind with the presented credentials. Service accounts, and people, whose credentials live in LDAP or Active Directory and should stop working the moment the directory says so. auth-ldap
bot_auth An RFC 9421 signature from a key in your agent directory, Ed25519 in the IETF Web Bot Auth pattern. You admit known AI crawlers by signature and reject anything merely claiming to be one. web-bot-auth
cap A capability token from a trusted issuer, carrying its own path and rate grants. Paid or contracted crawler traffic proves its grant per request. auth-cap

Scaffolding

Provider What it proves Reach for it when Example
noop Nothing. Every request passes. You want the config to say out loud that an origin is deliberately unauthenticated. One line of config; no example needed.

The same split, as a triage. The tables above carry the detail:

flowchart TD
    Q{"Who calls this origin?"} -->|"people at browsers\nand terminals"| P{"Where does the\ncredential live?"}
    Q -->|"services, agents,\nand crawlers"| M{"What can the\ncaller present?"}
    Q -->|"no one is challenged,\non purpose"| NOOP["noop"]
    P -->|"your IdP, as an\nSSO login"| OIDC["oidc"]
    P -->|"an auth service\nyou already run"| FA["forward_auth"]
    P -->|"a username and\npassword pair"| PW["basic_auth, or digest\nwhen a legacy system\ninsists on RFC 7616"]
    M -->|"issuer-signed\ntokens"| JWT["jwt"]
    M -->|"credentials in LDAP or\nActive Directory"| LDAP["ldap_auth"]
    M -->|"a crawler signature\nor grant"| BOT["bot_auth, or cap for\npaid contracted traffic"]
    M -->|"a static secret"| KEY["api_key or bearer;\nhmac_auth to keep it\noff the wire"]

Accepting more than one provider

authentication also takes a list of providers. The list is an OR: entries run in declared order and the first one that accepts the request wins.

  • Order matters. Every request walks the list from the top, so put the provider most callers use first.
  • The winner binds the request. Audit events, decision records, and the auth metric name the provider that authenticated, and principal attribution comes from the winning entry's own credential metadata. Nothing merges across entries.
  • Rejection is collective, challenges are merged. When every provider rejects, the response carries the first provider's status and message, with each provider's WWW-Authenticate challenge merged onto it (RFC 7235 allows several challenges on one response). A client that failed everywhere sees every scheme the origin accepts.
  • A failing provider loses only its own slot. Whatever the reason, the next entry still runs.

Four shapes are refused when the config compiles, each for a stated reason. A one-entry list: write a single provider as a plain mapping. noop in a list: it would admit every request and make the other entries decorative. forward_auth in a list: it runs as a separate subrequest and works only as an origin's sole provider. oidc in a list: it needs the login-callback endpoint that only a sole oidc block wires up.

The migration shape

The list exists mostly for credential cutovers. Keep the credential your callers hold today in the first slot, add the one you are moving them to underneath, and there is no flag day: both work on the same origin until the old one stops winning. This is examples/auth-composition/, runnable as shipped:

proxy:
  http_bind_port: 8080

origins:
  "api.local":
    action:
      type: proxy
      url: https://test.sbproxy.dev

    # Providers run top to bottom; the first success wins and names
    # itself in the audit and decision records. Put the provider most
    # callers still use first.
    authentication:
      - type: api_key
        header_name: X-Api-Key
        api_keys:
          - legacy-key-1
      - type: bearer
        tokens:
          - new-token-1

For a cutover to issuer-signed tokens instead, the second entry becomes a jwt block with your jwks_url and issuer; configuration.md shows exactly that pairing. Either way, progress is measurable: the access log's principal_kind column names the provider that won each request, so you can watch legacy traffic drain and delete the old entry when it reaches zero.

One round trip through that config, from a caller already moved to the new token:

sequenceDiagram
    participant C as Client
    participant P as SBproxy
    participant U as test.sbproxy.dev

    C->>P: GET /get, Host: api.local<br/>Authorization: Bearer new-token-1
    Note over P: authentication list: [api_key, bearer]
    P->>P: api_key finds no X-Api-Key header,<br/>loses its slot
    P->>P: bearer matches new-token-1 and wins
    P->>U: Request forwarded,<br/>bearer principal bound
    U-->>P: 200 OK
    P-->>C: 200 OK
    Note over P: Access log principal_kind names bearer,<br/>the winner, never the composite

What rides alongside authentication

  • Principal attribution. Each credential entry can carry project, user, team, tags, and free-form metadata; a match stamps them on the request principal, and they surface in the access log's principal_kind and attribution columns, in metric labels, and in policy scripts as principal.attrs.*. See per-credential metadata.
  • Decision records. Every auth allow and deny publishes an auth record on the decision-audit feed for SIEM consumers. The record carries the method, never the subject. See decision-records.md.
  • Trust tiers. Verifier outcomes feed the four-value trust tier: a verified bot_auth signature or CAP token earns strong, a failed one drops the request to suspicious, and policies read the result as request.trust_tier. See trust-tiers.md.
  • mTLS at the listener. proxy.mtls verifies client certificates in the TLS handshake, before any provider here runs, and passes the verified cert metadata upstream as X-Client-Cert-* headers. See mTLS client authentication and mtls-client-auth.
  • DPoP binding. bearer and jwt take require_dpop to demand an RFC 9449 proof on every request, and jwt additionally takes require_mtls_bound for RFC 8705 certificate-bound tokens. See sender-constrained Bearer. For the proofs SBproxy itself mints on upstream calls, see outbound-dpop.md.

Honest notes

Three providers make a network call while the request waits. ldap_auth binds against the directory on every request; bind results are deliberately not cached, so a password the directory revokes stops working immediately, and an unreachable directory answers 503 rather than admitting anyone. forward_auth sends one subrequest per request, and an unreachable auth service is likewise a 503, never a pass. oidc touches the IdP only during login, where the code exchange is a live POST to token_endpoint; requests carrying a session cookie authenticate locally until the session expires, so an IdP outage blocks new logins, not established sessions. Budget latency accordingly, and set the timeouts (http_client_timeouts, ldap_auth.timeout_secs) for your backends' real behavior. Everything else verifies against local state, with one caveat: providers configured with remote key material (jwt with a jwks_url, cap, bot_auth with a hosted directory) fetch and cache those keys over the network, and cap and bot_auth refuse rather than admit when a fetch fails.

Secrets in auth config ride the central resolver. API keys, tokens, HA1 hashes, HMAC secrets, and client secrets all accept the same reference forms as every other secret-bearing field: ${VAR}, env:, file:, or a backend URI such as vault://. A reference nothing can resolve refuses to boot instead of becoming the credential. See secrets.md.

See also

  • configuration.md - the field tables for all twelve providers.
  • api-gateway.md - where auth sits in the traditional reverse-proxy pillar.
  • key-management.md - dynamic virtual keys minted and revoked at runtime. A request authenticated by a minted key skips the origin's configured provider; the two paths are alternatives, never a chain.
  • object-authz.md - fine-grained authorization after a caller is authenticated.