Files
traefikoidc/docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md
T
lukaszraczylo a548665edb feat: opt-in M2M bearer-token authentication (supersedes #93) (#140)
* docs: bearer-token auth design spec

* docs: harden bearer-auth spec with security review findings

* feat(bearer): opt-in M2M bearer-token authentication

Adds an opt-in Authorization: Bearer <jwt> path for machine-to-machine
clients. Replaces and supersedes the broken approach in PR #93
(synthetic-session that omitted user_identifier and skipped ID-token
rejection / replay-protection-semantics / kid-pinning / etc.).

Design

  Two auth entrypoints feed one shared post-auth pipeline:

    cookie path  ─┐
                  ├── forwardAuthorized(rw, req, *principal)
    bearer path  ─┘    (roles/groups, header injection, security
                        headers, cookie strip, forward)

  buildPrincipalFromSession and buildPrincipalFromBearerToken produce
  the same `principal` value type. forwardAuthorized is session-agnostic
  and runs the existing post-auth work; processAuthorizedRequest now
  wraps it with the session-specific concerns (backchannel-logout,
  dirty/Save). The cookie path's behaviour is byte-identical to before
  this PR; the existing test suite passes unmodified.

Security hardening baked into the bearer path

  - Audience MANDATORY. Startup fails when EnableBearerAuth=true and
    Audience is empty.
  - BearerIdentifierClaim defaults to "sub"; "email" is rejected at
    startup to avoid the unverified-email spoofing footgun. Cookie
    path's UserIdentifierClaim is unaffected and still defaults to
    "email".
  - ID tokens explicitly rejected via the existing detectTokenType
    helper (nonce, typ=at+jwt, token_use, scope, aud-vs-clientID
    heuristics); belt-and-braces nonce/token_use=id rejection on top.
  - alg pinned to asymmetric allowlist (RS/PS/ES 256/384/512) BEFORE
    JWKS fetch, blocking alg=none and alg=HS* probes from amplifying
    into upstream calls.
  - kid length capped at 256 bytes and charset-restricted before JWKS
    fetch, blocking pathological-kid JWKS amplification.
  - Multi-audience tokens require azp == clientID.
  - iat upper-age bound (MaxTokenAgeSeconds, default 24h) bounds clock-
    manipulation and forever-token abuse.
  - Identifier sanitization: length cap, control-char + bidi-override
    + delimiter (, ; =) rejection.
  - Per-IP failure throttle: configurable threshold/window/penalty;
    returns 429 + Retry-After. Limits offline-guessing-style attacks
    and protects the shared rate-limiter / JWKS endpoint.
  - JTI replay marking suppressed via new internal verifyOpts
    {skipReplayMarking} so the same bearer can be reused until exp;
    the blacklist Get stays active so RevokeToken still terminates a
    bearer token immediately. The existing exported VerifyToken
    interface is unchanged so all mocks continue to work.
  - Cookie wins by default when both bearer and cookie are present
    (safer against browser/extension/proxy bearer injection).
    Operator can flip via BearerOverridesCookie.
  - Authorization header stripped on forward by default; also stripped
    on excluded URLs so the token can't leak into health/metrics
    downstream logs.
  - Optional RFC 7662 introspection via existing
    requireTokenIntrospection. Introspection-endpoint failure returns
    503 (distinguishes infra from token rejection).
  - 401s use RFC 6750 WWW-Authenticate hints (toggleable). Failure
    reason is logged at debug; raw tokens are never logged.

Implementation

  - principal.go: pure-data principal type and buildPrincipalFromSession.
  - bearer_auth.go: alg/kid pin, classifier, identifier sanitization,
    multi-aud azp gate, iat age check, per-IP failure tracker,
    handleBearerRequest, buildPrincipalFromBearerToken.
  - token_manager.go: VerifyToken now wraps a new verifyTokenWithOpts
    that accepts internal-only verifyOpts. Existing callers, the
    TokenVerifier interface, and all mocks unchanged.
  - middleware.go: extracted forwardAuthorized from
    processAuthorizedRequest; wired bearer detection after init wait
    + after bypass; excluded-URL Authorization strip when bearer
    enabled.
  - settings.go: ten new config fields with defaults applied in
    CreateConfig.
  - main.go: startup validation for audience + identifier-claim
    guard; bearer failure tracker init.

Tests

  - bearer_auth_test.go: table-driven helper tests for every new
    component (parseBearerJOSEHeader, sanitizeBearerIdentifier,
    resolveBearerIdentifier, enforceMultiAudienceAzp, enforceIatAge,
    bearerFailureTracker, detectBearerToken). Integration tests
    through ServeHTTP covering happy path, ID-token rejection,
    alg=none rejection, oversized kid, multi-aud with/without azp,
    iat-too-old, bidi identifier, replay (100x reuse), 429 throttle
    trip, excluded-URL strip, roles gate, cookie-wins precedence,
    BearerOverridesCookie, oversized token, malformed JWT,
    feature-off pass-through. Startup validation for audience-
    required and email-identifier-rejected.
  - All existing tests pass unmodified (cookie-path regression).
  - go vet clean. golangci-lint clean (0 issues). Race detector
    clean on bearer tests.

Documentation

  - README.md: bearer auth section with security highlights and
    config snippet; doc link in the index.
  - .traefik.yml: commented config block exposing every bearer knob.
  - docs/CONFIGURATION.md: new subsection with full parameter table.
  - docs/BEARER_AUTH.md: threat model, hardening matrix, failure
    response table, operational guidance, known follow-ups.
  - docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md:
    design spec + security-review hardening history.

* fix(cache): redact raw cache keys in debug logs (CodeQL go/clear-text-logging)

CodeQL flagged 9 high-severity alerts (go/clear-text-logging) where the
in-memory cache and the hybrid L1+L2 backend printed `key=%s` at debug.
Cache callers (token cache, blacklist, introspection cache) pass raw
access / refresh / id tokens as cache keys, so any debug-enabled
deployment would write them to log streams.

Pre-existing issue. CodeQL started flagging it on this PR because the
new bearer-auth path adds a data-flow source (req.Header.Get("Authorization"))
that reaches the existing logging sinks via the same cache. The cookie
path had the same risk but wasn't tracked as taint by CodeQL.

Fix: hash the key (SHA-256[:8] hex) before printing. Same approach the
bearer-auth logger uses for principal identifiers (spec §13). Doesn't
change cache semantics — same key still produces the same hash, so
debug correlation across log lines is preserved without exposing the
raw value.

Touches both affected packages:
  - internal/cache/cache.go (2 sites: Set + LRU eviction)
  - internal/cache/backends/hybrid.go (12 sites: L1/L2 read/write/fallback)

New helper `redactKey` colocated with each package (unexported,
package-local) keeps the change blast radius narrow. Tests green; lint
clean.

* docs(bearer): how to obtain bearer tokens from the OIDC provider

Adds a section walking operators through the OAuth 2.0 client_credentials
flow (RFC 6749 §4.4) and the JWT bearer assertion alternative (RFC 7523),
with a worked Auth0-shape curl example, a per-provider quick reference
(Auth0, Okta, Keycloak, Entra v2, Cognito, GitLab, Google), operational
notes (token TTL, caching, JWKS rotation, revocation, scope vs audience,
secret hygiene), and a three-line validation loop.

Most common operator confusion: "I enabled the feature but tokens get
401'd" — almost always missing or wrong audience. The new section makes
the audience-matching requirement loud, with per-provider parameter
names so people don't have to dig through IdP docs.

Locations:
  - docs/BEARER_AUTH.md  — full section under "Quick start"
  - README.md            — short snippet + deep link
2026-05-18 17:35:37 +01:00

32 KiB
Raw Blame History

Bearer Token Authentication — Design Spec

  • Date: 2026-05-18
  • Status: Design — pending implementation plan
  • Supersedes: PR #93 (broken implementation; recommended to close in favour of this design)

1. Summary

Add an opt-in path that lets API clients (machine-to-machine) authenticate by presenting a signed access token in the Authorization: Bearer <token> header, bypassing the cookie-based OIDC redirect flow. Identity, roles, and authorization checks remain consistent with the existing cookie path; the only thing that changes is how the principal is established for that single request.

The feature is implemented by extracting a shared forwardAuthorized pipeline from the existing processAuthorizedRequest, introducing a principal value type, and adding a small bearer-specific entrypoint that builds a principal directly from a verified JWT — without synthesising a fake SessionData.

2. Motivation

PR #93 attempted this feature by building an in-memory SessionData from JWT claims and reusing processAuthorizedRequest. The approach has three latent defects:

  1. The synthetic session omits mainSession.Values["user_identifier"]. processAuthorizedRequest reads it via GetUserIdentifier(); when empty it bails to defaultInitiateAuthentication and issues an OIDC redirect. The feature is non-functional in practice despite the unit test passing.
  2. verifyToken accepts both ID tokens (audience match against clientID) and access tokens. ID tokens are not API credentials; treating them as such is a classic token-confusion vector.
  3. verifyToken adds JTI to the replay blacklist on first verify. Once the verified-token cache evicts, subsequent reuse of the same bearer token triggers a false-positive replay rejection.

Rather than patch a synthetic-session approach that will keep generating bugs as SessionData evolves, this spec replaces it with a cleaner abstraction where session lifecycle and post-auth header injection live in separate units.

3. Goals

  • Accept Authorization: Bearer <jwt> from M2M clients, validate the token, and forward the request downstream with identity headers populated.
  • Enforce the same allowedRolesAndGroups policy as the cookie path.
  • Default-off; safe defaults when enabled (audience required, ID tokens rejected, identifier sanitised).
  • No behavioural change to the cookie path. Existing tests must continue to pass without modification.

4. Non-Goals

  • Human-user / browser flows. Bearer is M2M-only in this iteration.
  • Pure opaque access tokens on the bearer path. Tokens must be JWTs; introspection (RFC 7662) is supported on top of JWT verification for revocation state, not as a substitute for it.
  • mTLS, API keys, or any other auth method. The principal abstraction enables them later, but they are not delivered here.
  • Per-route bearer configuration. Single middleware-wide setting.

5. Decided Requirements

Topic Decision
Consumer type Machine-to-machine (M2M) only
Token format JWT only (signature, issuer, audience, exp)
Audience Mandatory when feature enabled; startup fails if Audience == ""
Token type Access tokens only; ID tokens explicitly rejected
Revocation JWT-only verification by default; introspection (RFC 7662) opt-in via existing RequireTokenIntrospection
Identity claim New BearerIdentifierClaim config (string, default "sub"). Bearer path reads this claim exclusively; does NOT use UserIdentifierClaim (which defaults to "email" and drives the cookie path). Resolved value must be a non-empty string. sub is mandatory per jwt.go:416 regardless, so even with a different BearerIdentifierClaim the token must still carry a valid sub. Decoupling avoids the M2M-vs-human-user identity-claim conflict and the email-spoofing footgun.
Identifier sanitisation Reject value containing any unicode.IsControl char, any Unicode bidi-override (U+202AU+202E, U+2066U+2069), leading/trailing whitespace, commas, semicolons, equals signs. Max length 256 bytes.
Token classifier Reuse existing detectTokenType(jwt, token) at token_manager.go:187-303 which already handles nonce, typ: at+jwt, token_use, scope, and aud-vs-clientID priority. Bearer path rejects any token where detectTokenType == true (ID token). Do not invent a parallel classifier.
Algorithm pinning Hard-pin alg ∈ {RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512}, enforced before JWKS lookup on the bearer path. Prevents wasted JWKS fetches for alg=none/HS attacker probes.
kid hardening kid ≤ 256 bytes, charset [A-Za-z0-9._\-=]. Reject before JWKS lookup.
Token age Bearer path enforces now - iat <= MaxTokenAgeSeconds (default 86400 / 24h, configurable). Cookie path unchanged.
Multi-audience policy If aud is an array (length > 1), require azp claim to be present and equal to clientID. Single-string aud unaffected.
Mixed bearer + cookie precedence Cookie wins by default when both are presented (safer for browser scenarios). Operator opt-in: BearerOverridesCookie=true to flip. Either way, a warning is logged on the request.
Bearer + excluded URL Authorization header is stripped before forwarding when the request hits an excluded URL. Prevents bearer leaking into public endpoints' downstream logs and prevents recon via excluded paths.
Per-source bearer 401 throttle New sharded cache failedBearerAttempts keyed by client IP. After N (default 20) consecutive 401s from one IP within 1 minute, reject further bearer requests from that IP with 429 for 60s. Applied BEFORE verifyToken to deny JWKS amplification.
Authorization header passthrough New StripAuthorizationHeader config, default true
Roles/groups gating Same allowedRolesAndGroups rules as cookie path
Default state EnableBearerAuth = false
JTI replay marking Suppressed on bearer path; cookie path unchanged
Failure response shape 401 with generic body; WWW-Authenticate: Bearer error="invalid_token" per RFC 6750
Introspection endpoint outage 503 (distinguishes infra outage from token rejection)
Mixed bearer + cookie Bearer wins; cookie ignored on that request
SSE/WS bypass + bearer Bypass paths keep cookie-only check; bearer header ignored on SSE/WS

6. Architecture

                ┌──────────────────┐
   HTTP req ──► │   ServeHTTP      │  (existing entry; adds bearer detection)
                └─────────┬────────┘
              ┌───────────┴────────────┐
              ▼                        ▼
        cookie / session         bearer (Authorization: Bearer …)
              │                        │
              ▼                        ▼
    ┌────────────────┐        ┌────────────────────┐
    │ buildPrincipal │        │ buildPrincipal     │
    │ FromSession()  │        │ FromBearerToken()  │
    └────────┬───────┘        └─────────┬──────────┘
             │     produces *principal  │
             └──────────────┬───────────┘
                            ▼
              ┌────────────────────────────┐
              │ forwardAuthorized(rw,req,p)│  (shared pipeline)
              │  • roles/groups gate       │
              │  • header injection        │
              │  • header templates        │
              │  • security headers        │
              │  • cookie stripping        │
              │  • next.ServeHTTP          │
              └────────────────────────────┘

Invariant: forwardAuthorized never touches session storage. Session-specific concerns (Save, IsDirty, backchannel-logout invalidation) stay inside processAuthorizedRequest around the call to forwardAuthorized.

Feature gate: when EnableBearerAuth == false, the bearer-detection check in ServeHTTP is a no-op. Existing deployments observe byte-identical behaviour.

7. Components

7.1 principal type (new file principal.go)

type principalSource int

const (
    sourceSession principalSource = iota
    sourceBearer
)

type principal struct {
    Identifier   string                 // drives X-Forwarded-User
    Email        string                 // optional, "" for M2M
    Subject      string                 // sub claim
    ClientID     string                 // azp / client_id, M2M caller
    Claims       map[string]interface{} // raw claims for templates / groups
    AccessToken  string                 // for X-Auth-Request-Token (gated by minimalHeaders)
    IDToken      string                 // "" on bearer path
    RefreshToken string                 // "" on bearer path
    Source       principalSource
}

Pure data. No methods that mutate it. No I/O. No manager pointer.

7.2 buildPrincipalFromSession(*SessionData) *principal (new in principal.go)

Read-only adapter over existing SessionData getters: GetUserIdentifier, GetEmail, GetAccessToken, GetIDToken, GetRefreshToken, cached claims via GetIDTokenClaims. Does not write back to the session. This is the only function that still knows about SessionData.

7.3 buildPrincipalFromBearerToken(token string) (*principal, error) (new in bearer_auth.go)

  1. Length / format guards: len(token) <= AccessTokenConfig.MaxLength, exactly two dots, non-empty after trim.
  2. Parse header for early alg/kid pinning (without trusting payload): decode JOSE header; reject if alg ∉ asymmetric allowlist; reject if kid missing, > 256 bytes, or contains chars outside [A-Za-z0-9._\-=]. This happens before JWKS lookup so attacker noise doesn't amplify into JWKS fetches.
  3. Per-IP 401 throttle check: if this IP is in the failedBearerAttempts penalty box, return 429 immediately.
  4. t.verifyToken(token, verifyOpts{skipReplayMarking: true}) — reuses signature, issuer, audience, expiration, JTI Get (replay detection). The skipReplayMarking flag gates ONLY the JTI Set at token_manager.go:108-143; the JTI Get at token_manager.go:44-47, 80-89 remains active so revoked tokens (via RevokeToken adding to blacklist) are still rejected.
  5. Re-parse claims (parseJWT(token) is cheap and already done internally; reuse via a single decode if practical).
  6. Token-type guard: call existing detectTokenType(jwt, token) (token_manager.go:187-303). Reject when it returns true (ID token). Belt-and-braces: also reject if claims["nonce"] is a non-empty string or claims["token_use"] == "id".
  7. Multi-audience hardening: if claims["aud"] is a []interface{} with length > 1, require claims["azp"] to be a non-empty string equal to t.clientID; reject otherwise.
  8. iat upper-age bound: reject when time.Now().Unix() - int64(claims["iat"].(float64)) > MaxTokenAgeSeconds (default 86400).
  9. Optional introspection: if requireTokenIntrospection is set, call introspectToken; reject if active == false (401); surface 503 on transport failure. Bearer-path introspection cache TTL is capped at 60s (not 5min) to keep the "real-time revocation" promise close to true.
  10. Identifier resolution: read t.bearerIdentifierClaim (defaults to "sub"); do NOT use t.userIdentifierClaim (cookie path's setting, default email). The bearer path does NOT fall back to other claims because jwt.Verify already enforces non-empty sub (jwt.go:416-419). Empty/missing identifier → 401.
  11. Identifier sanitisation: trim, then reject if length > 256 OR contains any of: unicode.IsControl, bidi-override (U+202AU+202E, U+2066U+2069), ,, ;, =.
  12. Return &principal{ Source: sourceBearer, … }.

On any failure path: increment the per-IP failedBearerAttempts counter; return the appropriate HTTP status (401 / 403 / 429 / 503) without revealing the failure reason in the response body. Reason is logged at debug only, with the identifier (if resolved) hashed via SHA-256 truncated to 8 hex chars.

7.4 forwardAuthorized(rw, req, *principal) (new in middleware.go, extracted)

The shared post-auth pipeline. Lifted verbatim from the existing processAuthorizedRequest:

  1. Roles/groups extraction via existing extractGroupsAndRolesFromClaims.
  2. allowedRolesAndGroups gate (existing logic).
  3. Inject X-Forwarded-User, X-User-Groups, X-User-Roles.
  4. Inject X-Auth-Request-* (gated by minimalHeaders).
  5. Header templates.
  6. Security headers.
  7. Cookie strip when stripAuthCookies.
  8. New: Authorization header strip when stripAuthorizationHeader AND principal.Source == sourceBearer.
  9. t.next.ServeHTTP(rw, req).

Does not call Save, does not check IsDirty. Session persistence stays with the cookie-path caller.

7.5 handleBearerRequest(rw, req) (new in bearer_auth.go)

1. Detect "Authorization: Bearer <token>" (case-insensitive prefix).
2. token = TrimSpace(authHeader[7:]); reject empty.
3. p, err := buildPrincipalFromBearerToken(token).
   On err → 401 with WWW-Authenticate, log reason at debug.
4. forwardAuthorized(rw, req, p).

Target: ~40 lines.

7.6 Refactor of processAuthorizedRequest (modify middleware.go)

Splits along the principal boundary:

  • Session-specific part (backchannel-logout invalidation, IsDirty / Save) stays in processAuthorizedRequest.
  • Everything else moves to forwardAuthorized.
  • processAuthorizedRequest ends with forwardAuthorized(rw, req, buildPrincipalFromSession(session)).

7.7 verifyOpts extension to verifyToken (modify token_manager.go)

Add a parameter struct:

type verifyOpts struct {
    skipReplayMarking bool // suppress JTI Set (token_manager.go:108-143); blacklist Get stays active
}

Both the type and field are unexported (internal-only knob). Signature change: verifyToken(token string) becomes verifyToken(token string, opts verifyOpts). Existing callers pass verifyOpts{} (zero value = current behaviour). Bearer path passes verifyOpts{skipReplayMarking: true}.

Critical semantics — must be reflected in implementation and tests:

  • skipReplayMarking only gates the Set at token_manager.go:108-143 (the call adding the JTI to the blacklist and replay cache).
  • The blacklist Get at token_manager.go:44-47, 80-89 stays unconditionally active on the bearer path. Tokens revoked via RevokeToken (which adds the JTI to the blacklist) MUST still be rejected on the bearer path.
  • Must NOT be implemented by mutating t.disableReplayDetection (struct field) — that would create a cross-request race that disables replay protection globally.

A targeted regression test exercises: bearer token verified once → admin calls RevokeToken adding the JTI to the blacklist → same token replayed → 401.

7.8 Config additions (modify settings.go)

EnableBearerAuth              bool   `json:"enableBearerAuth,omitempty"`
BearerIdentifierClaim         string `json:"bearerIdentifierClaim,omitempty"`
StripAuthorizationHeader      bool   `json:"stripAuthorizationHeader,omitempty"`
BearerEmitWWWAuthenticate     bool   `json:"bearerEmitWWWAuthenticate,omitempty"`
BearerOverridesCookie         bool   `json:"bearerOverridesCookie,omitempty"`
MaxTokenAgeSeconds            int64  `json:"maxTokenAgeSeconds,omitempty"`
MaxIdentifierLength           int    `json:"maxIdentifierLength,omitempty"`
BearerFailureThreshold        int    `json:"bearerFailureThreshold,omitempty"`
BearerFailureWindowSeconds    int    `json:"bearerFailureWindowSeconds,omitempty"`
BearerFailurePenaltySeconds   int    `json:"bearerFailurePenaltySeconds,omitempty"`

Defaults (applied in CreateConfig for the bearer-related fields; values >0 only honoured when EnableBearerAuth=true):

  • EnableBearerAuth: false.
  • BearerIdentifierClaim: "sub".
  • StripAuthorizationHeader: true.
  • BearerEmitWWWAuthenticate: true (RFC 6750 hint enabled by default; flip to false if recon-exposure is a concern).
  • BearerOverridesCookie: false (cookie wins when both present; flip to true for the legacy/industry-default behaviour).
  • MaxTokenAgeSeconds: 86400 (24h upper bound on iat).
  • MaxIdentifierLength: 256.
  • BearerFailureThreshold: 20 (consecutive 401s per IP before throttle).
  • BearerFailureWindowSeconds: 60.
  • BearerFailurePenaltySeconds: 60 (429 reply for this long after threshold tripped).

7.9 Startup validation (modify main.go New())

  • EnableBearerAuth && Audience == "" → fatal error.
  • EnableBearerAuth && !StrictAudienceValidation → warning log (recommended hardening).
  • EnableBearerAuth && BearerIdentifierClaim == "email" → fatal error (the bearer path is M2M and an email identifier without email_verified enforcement is a spoofing vector; default BearerIdentifierClaim=sub avoids this; explicit override to email is rejected).
  • EnableBearerAuth && MaxTokenAgeSeconds <= 0 → reset to default 86400 with info log.
  • EnableBearerAuth && BearerFailureThreshold <= 0 → reset to default 20 with info log.

8. Data Flow

8.1 Bearer path

ServeHTTP entry (pre-init paths unchanged: logout, backchannel, frontchannel, excluded URLs, SSE/WS bypass)
  │
  ├─ enableBearerAuth == false?  → fall through to cookie path
  │
  └─ enableBearerAuth == true AND Authorization starts with "Bearer "
       │
       ▼
  handleBearerRequest
       │
       ├─ format guards (empty, length, segment count)
       │
       ▼
  verifyToken(token, verifyOpts{SkipReplayMarking: true})
       │  signature, issuer, audience (strict), exp
       │
       ▼
  classifyToken(claims) → reject ID tokens
       │
       ▼
  if requireTokenIntrospection: introspectToken → active check
       │
       ▼
  resolveIdentifier(claims) → sanitiseIdentifier
       │
       ▼
  principal{Source: sourceBearer, …}
       │
       ▼
  forwardAuthorized(rw, req, principal)
       │
       ├─ roles/groups gate (403 on deny)
       ├─ header injection
       ├─ header templates
       ├─ security headers
       ├─ strip OIDC cookies (existing)
       ├─ strip Authorization header (new, when configured)
       └─ next.ServeHTTP(rw, req)
processAuthorizedRequest
  1. Session validity / backchannel-logout invalidation (unchanged).
  2. principal := buildPrincipalFromSession(session).
  3. forwardAuthorized(rw, req, principal).
  4. if session.IsDirty(): session.Save().

9. Error Handling

Trigger Status Body WWW-Authenticate Debug log reason
Empty bearer after prefix 401 Unauthorized Bearer error="invalid_request" empty bearer token
Token over MaxLength 401 Unauthorized Bearer error="invalid_token" token exceeds max length
Not a 3-segment JWT 401 Unauthorized Bearer error="invalid_token" malformed JWT
Disallowed alg (e.g. none, HS*) 401 Unauthorized Bearer error="invalid_token" unsupported alg
Missing/oversized/bad-charset kid 401 Unauthorized Bearer error="invalid_token" invalid kid
Signature / issuer / aud / exp fail 401 Unauthorized Bearer error="invalid_token" reason from verifyToken (category only)
iat older than MaxTokenAgeSeconds 401 Unauthorized Bearer error="invalid_token" token too old (iat outside age bound)
Multi-aud without matching azp 401 Unauthorized Bearer error="invalid_token" multi-aud token without azp match
Detected as ID token 401 Unauthorized Bearer error="invalid_token" ID tokens not accepted on bearer path
JTI blacklisted (revoked) 401 Unauthorized Bearer error="invalid_token" token JTI in blacklist
Introspection active=false 401 Unauthorized Bearer error="invalid_token" token inactive at IdP
Introspection endpoint failure 503 Service Unavailable (none) introspection unavailable
Identifier claim missing/empty 401 Unauthorized Bearer error="invalid_token" no identifier claim
Identifier fails sanitisation 401 Unauthorized Bearer error="invalid_token" invalid identifier characters
Per-IP failure threshold tripped 429 Too Many Requests (none); Retry-After: <BearerFailurePenaltySeconds> source IP in penalty box
Roles/groups not allowed 403 Access denied (none) user not in allowedRolesAndGroups

Responses never include token contents, never include the raw failure reason, and never set Location headers (API clients cannot follow redirects).

10. Edge Cases

  1. Both bearer header and cookie session present. Cookie wins by default (safer against browser/extension/proxy bearer injection). BearerOverridesCookie=true flips to bearer-wins. Either way: WARN log includes both source markers so operators can audit.
  2. Authorization: Basic …. Not bearer; cookie path runs as today.
  3. Authorization: Bearer (trailing space, no value). Empty after trim → 401.
  4. Mixed-case prefix (bearer, BEARER, BeArEr). Case-insensitive prefix check; token value preserved verbatim.
  5. Multiple Authorization headers. Use only the first (Go http.Header.Get default). Documented.
  6. Bearer during OIDC init wait. Bearer requests also block on init: we need issuerURL, audience, JWKs ready. If init fails, bearer requests return 503 just like cookie requests.
  7. SSE / WebSocket bypass with bearer. Bypass paths keep cookie-only behaviour. Operators who want bearer on streaming endpoints must remove SSE/WS bypass. Documented.
  8. Logout endpoint with bearer. Logout runs before bearer detection. Treated as cookie-session logout; bearer token revocation requires IdP-side action.
  9. Excluded URLs with bearer. Bypass excluded URLs as today; bearer not validated on excluded paths. ADDITIONALLY: Authorization: Bearer is stripped from the request before forwarding so the token can't leak into the excluded endpoint's downstream logs / metrics scrapers / health checks.
  10. Concurrent identical bearer requests. Existing tokenCache is concurrency-safe; no new locking.
  11. Client rotates token between requests. Independent verification per token; independent cache entries.
  12. Clock skew. Use existing jwt.Verify leeway. (If absent, add ±30s as a separate change; out of scope here.)

11. Testing Strategy

11.1 Integration tests (new bearer_auth_test.go)

Table-driven test against a real httptest.Server and the full ServeHTTP flow. Coverage matrix:

  • Valid access token + allowed roles → 200, next ran, X-Forwarded-User set.
  • Valid token without configured roles → 200.
  • Wrong audience, expired, tampered signature → 401, next did not run.
  • ID token presented → 401 (ID tokens not accepted).
  • Malformed JWT (2 segments) → 401.
  • Oversized token (> MaxLength) → 401.
  • Empty bearer → 401.
  • Missing identifier claim → 401.
  • Identifier containing \r\n → 401.
  • allowedRolesAndGroups mismatch → 403.
  • allowedRolesAndGroups match → 200.
  • EnableBearerAuth=false + bearer header → cookie path runs (302 to /authorize).
  • Bearer + valid cookie session → bearer wins, 200.
  • StripAuthorizationHeader=true → downstream sees no Authorization.
  • StripAuthorizationHeader=false → downstream sees Authorization.
  • Case variants (bearer, BEARER) → 200.
  • SSE bypass + bearer → cookie-only check applies (bearer ignored).
  • Replay regression: same token 1000 times in a row → all 200.
  • Cache-evict regression: same token, force-evict tokenCache between iterations (call tokenCache.Delete directly), replay → still 200 (verifies skipReplayMarking doesn't poison the blacklist).
  • Revocation-while-bearer regression: bearer token verified once → admin calls RevokeToken adding JTI to blacklist → same token presented → 401 (verifies blacklist Get stays active on bearer path even with skipReplayMarking set).
  • Alg-pin: token signed with alg=none → 401, no JWKS fetch happens (verify with a counting mock).
  • kid injection: 50KB random kid → 401 immediately, no JWKS fetch.
  • Per-IP throttle: 21 bad bearer requests from same IP within 1 minute → 22nd returns 429 + Retry-After.
  • iat upper-age: token with iat = now - 25h → 401 (older than 24h default).
  • Multi-aud without azp: aud = ["a", "b"], no azp → 401.
  • Multi-aud with matching azp: aud = ["api-aud", "other"], azp = clientID → 200.
  • Identifier with bidi-override: sub contains U+202E → 401.
  • Identifier with comma: sub = "alice,bob" → 401.
  • Identifier over 256 bytes → 401.
  • UserIdentifierClaim=email at startup with EnableBearerAuth=true → startup fails.
  • Excluded URL + bearer: bearer header presented on excluded URL → request forwarded, downstream sees no Authorization header (stripped).

11.2 Unit tests (in bearer_auth_test.go)

  • classifyToken: ID-token detection, access-token detection by scope/scp/token_use, ambiguous → reject.
  • resolveIdentifier: precedence (userIdentifierClaimsubclient_id/azp); missing → error; empty string → error.
  • sanitizeIdentifier: rejects all unicode.IsControl; accepts email/sub-style values.

11.3 Introspection tests (bearer_auth_introspection_test.go)

  • Token valid + introspection active=true → 200.
  • Token valid + introspection active=false → 401.
  • Introspection endpoint 500 → 503.
  • Second request hits introspection cache (no second HTTP call).

11.4 Startup validation tests (extend settings_test.go / main_test.go)

  • EnableBearerAuth=true, Audience=""New() errors.
  • EnableBearerAuth=true, StrictAudienceValidation=false → succeeds with warning.
  • EnableBearerAuth=false → no validation; existing tests untouched.
  • All existing TestServeHTTP_* tests in main_servehttp_test.go pass unmodified.
  • Add: cookie session, EnableBearerAuth=true, no bearer header → identical behaviour to baseline.
  • Add: dirty session still triggers Save() after refactor.

11.6 Principal invariants

  • buildPrincipalFromSession: Source == sourceSession; IDToken / RefreshToken populated when present in session.
  • buildPrincipalFromBearerToken: Source == sourceBearer; IDToken == "", RefreshToken == "".
  • forwardAuthorized produces identical headers for equivalent principals regardless of source.

11.7 Coverage gate

  • New code in bearer_auth.go and principal.go: ≥ 90% line coverage.
  • forwardAuthorized coverage ≥ existing processAuthorizedRequest coverage baseline.

11.8 Out of scope (follow-ups)

  • Load test of bearer vs cookie hot path.
  • Fuzzing the JWT parser.
  • Additional auth methods (mTLS, API keys) — design enables them, but they are separate work.

12. Migration / Rollout

Default-off. Existing deployments observe no behavioural change. Operators opt in by setting:

enableBearerAuth: true
audience: https://api.example.com   # required when bearer enabled
# optional:
stripAuthorizationHeader: true       # default
requireTokenIntrospection: false     # default; set true for real-time revocation
userIdentifierClaim: client_id       # optional override; defaults to sub fallback chain

Documentation: update docs/CONFIGURATION.md with a bearer-auth section, and add a new docs/BEARER_AUTH.md covering the security model, threat assumptions (token issuer is trusted; audience must be set; bearer means trust the issuer's revocation policy unless introspection enabled), and recommended configurations for common IdPs.

13. Security Considerations

Concern Mitigation
Token confusion (ID token used as bearer) Reuse detectTokenType (token_manager.go:187-303) which checks nonce, typ: at+jwt, token_use, scope, aud-vs-clientID. Belt-and-braces: explicit nonce + token_use == "id" rejection on top.
Audience confusion (token for service B accepted by A) Audience mandatory at startup; verified via existing VerifyJWTSignatureAndClaims; multi-aud tokens require matching azp == clientID.
Replay-via-blacklist false positive verifyOpts{skipReplayMarking: true} on bearer path. Gates ONLY the Set; the Get stays so revoked tokens still fail.
Revocation lag Optional RFC 7662 introspection. Bearer-path introspection cache TTL capped at 60s. Set RequireTokenIntrospection=true for real-time revocation.
alg-confusion / alg=none attacks Hard-pin asymmetric allowlist at bearer entry, before JWKS fetch. Prevents wasted upstream calls and locks out HS/none probes.
kid injection / JWKS amplification kid length cap (256 bytes) + charset allowlist enforced at bearer entry.
Bearer 401 brute-force / oracle Per-IP failedBearerAttempts cache; configurable threshold + penalty box returning 429 + Retry-After.
iat clock-manipulation / forever-tokens MaxTokenAgeSeconds upper bound (default 24h); cookie path unchanged.
Identifier-driven header injection sanitizeIdentifier: length cap, control-char + bidi-override + ,;= rejection. net/http rejects CRLF on the wire too (defence in depth).
Token leakage downstream StripAuthorizationHeader=true by default. Also: Authorization stripped on excluded-URL requests so bearer can't leak into health/metrics downstream logs.
Token-in-logs All log paths log reason categories, not raw tokens. Identifier hashed via SHA-256 truncated to 8 hex chars before any info/warn-level emission (full identifier only at debug). New safeLogAuthEvent(category, hashedIdentifier, reasonCode) helper makes this hard to misuse.
email claim spoofing Startup fails if EnableBearerAuth && UserIdentifierClaim == "email". Future human-user bearer iteration must add email_verified enforcement.
Bypass on SSE / WS endpoints SSE/WS bypass keeps cookie-only behaviour; bearer ignored. Operators choose to widen if needed.
Mixed bearer + cookie precedence Cookie wins by default (safer for browser scenarios); BearerOverridesCookie=true flips. WARN log on both-present requests.
Configuration drift (operator forgets audience) Startup fails when EnableBearerAuth=true && Audience=="".
Downstream blast radius when StripAuthorizationHeader=false Documented: forwarded bearer extends token's blast radius to all downstream services. Logs at those services become token stores. Operators must treat downstream log policy accordingly.
Introspection auth method (pre-existing gap, called out) token_introspection.go:80 uses client_secret_basic only; does not honour private_key_jwt. Out of scope for this PR but documented as a follow-up; operators using ClientAuthMethod=private_key_jwt + RequireTokenIntrospection=true should be aware introspection will use basic auth.

14. Open Questions

None — all design decisions resolved during brainstorming + security review. Implementation may surface incidental questions (e.g. exact clock-skew leeway in jwt.Verify); those are out of scope for this spec and handled in the implementation plan.

14a. Security Review Reference

This design was reviewed by the security-reviewer subagent on 2026-05-18. Findings incorporated:

  • Critical: C1 (classifier reuses detectTokenType), C2 (sub fallback dropped — unreachable due to jwt.go:416), C3 (replay-marking gates only Set, not Get; revocation regression test added).
  • High: H1 (alg pinned at bearer entry), H2 (kid length + charset), H3 (cookie wins by default, configurable), H4 (per-IP 401 throttle), H5 (multi-aud requires azp).
  • Medium: M1 (identifier max-length + bidi reject + delimiter chars), M2 (introspection cache TTL capped at 60s on bearer path), M4 (log-hashing via SHA-256[:8]), M5 (StripAuth blast-radius documented), M6 (iat upper-age bound), M7 (Authorization stripped on excluded URLs).
  • Low/Nit: L2 (renamed to BearerEmitWWWAuthenticate), N3 (startup rejects UserIdentifierClaim=email).
  • Documented as pre-existing gaps (follow-up PRs): M3 (introspection auth method doesn't honour private_key_jwt).

15. Implementation Plan Reference

To be produced by the writing-plans skill in a follow-up document at docs/superpowers/plans/2026-05-18-bearer-token-auth-plan.md. The plan decomposes this design into ordered, independently-testable PRs.