Skip to content

BearerTokenAuthMiddleware: Authorization: Bearer should be the default; legacy header should be opt-in alias #720

Description

@bokelley

Summary

BearerTokenAuthMiddleware is named after the spec-canonical RFC 6750 Authorization: Bearer header but ships with a single configurable header_name that defaults to "authorization" and accepts no aliases. Adopters with early clients sending tokens via a custom header (x-adcp-auth, X-Api-Key, etc.) face a forced binary choice during migration: lock the middleware to the legacy header (breaks every spec-compliant new client, including @adcp/sdk comply()'s api_key probe path) OR switch to Authorization: Bearer (breaks every early adopter still on the legacy header).

The right model is: Authorization: Bearer is always accepted (the spec-canonical default), legacy aliases are an opt-in additive list. Both work simultaneously; migration is a one-way drift from legacy clients to spec-compliant ones without flag days.

Concrete adopter pain (Prebid salesagent)

The Prebid salesagent ships mcp_header_name="x-adcp-auth" because early adopters were sending tokens that way before the protocol settled on Authorization: Bearer. Every authenticated MCP call works fine for those clients. But every spec-compliant client — including the SDK's own security_baseline api_key probe, which sends Authorization: Bearer per RFC 6750 — gets 401 against the same valid token:

$ curl -i -X POST <agent>/mcp \
    -H "Authorization: Bearer <TOKEN>" \
    -H "Content-Type: application/json" -d '<jsonrpc body>'
HTTP/2 401
www-authenticate: Bearer
{"error":"unauthenticated"}

$ curl -i -X POST <agent>/mcp \
    -H "x-adcp-auth: <TOKEN>" \
    -H "Content-Type: application/json" -d '<jsonrpc body>'
HTTP/2 200

Net consequence: the salesagent fails the security_baseline/probe_api_key and assert_mechanism storyboard steps despite having RFC-correct auth — its auth path simply doesn't read the spec header. Filed locally as bokelley/salesagent#386 with a wrapper-middleware workaround that normalizes Authorization: Bearerx-adcp-auth before delegating to BearerTokenAuthMiddleware. Every adopter mid-migration is going to write the same wrapper.

Proposed API

Replace the singular header_name: str with a list shape, with Authorization: Bearer baked in as always-accepted (cannot be turned off — it's the spec, and it's the class's own name).

class BearerTokenAuthMiddleware(BaseHTTPMiddleware):
    def __init__(
        self,
        app: Any,
        *,
        validate_token: TokenValidator,
        unauthenticated_response: dict[str, Any] | None = None,
        # NEW: opt-in additional aliases for legacy clients. The
        # spec-canonical ``Authorization: Bearer ...`` is ALWAYS
        # accepted; this is purely additive.
        legacy_header_aliases: list[str] | None = None,
        # NEW: bearer_prefix_required applies only to the legacy
        # aliases (``Authorization`` is RFC 6750 — always requires
        # the ``Bearer `` prefix).
        legacy_aliases_bearer_prefix_required: bool = False,
    ) -> None: ...

Pre-request resolution order:

  1. Authorization: Bearer <token> (RFC 6750 §2.1) — always checked first.
  2. Each entry in legacy_header_aliases in order — first non-empty wins.
  3. Missing / empty → 401.

Adopters with strict requirements can pin a single header by passing legacy_header_aliases=None (default) — they get spec-canonical only. Adopters migrating clients add legacy_header_aliases=["x-adcp-auth"] and both paths work simultaneously.

Backward-compat strategy

The existing header_name / bearer_prefix_required params should be:

  1. Deprecated with a DeprecationWarning on construction. Map to the new shape internally — passing header_name="x-adcp-auth" produces legacy_header_aliases=["x-adcp-auth"] AND keeps Authorization: Bearer accepted (this is the behaviour change: legacy header config currently EXCLUDES the canonical header; the new shape makes them ADDITIVE).

  2. Removed in the next major (6.0 / 7.0 depending on release cadence).

The behaviour change is a strict improvement for every adopter — no one wants to actively reject the spec-canonical header on a middleware named BearerTokenAuth. But it deserves a major-bump entry in the changelog because adopters who relied on header_name acting as an EXCLUSIVE filter (likely zero, but possible) will see Authorization: Bearer start arriving.

Why WWW-Authenticate: Bearer already says so

The 401 response already advertises WWW-Authenticate: Bearer (per adcp-client-python#712 once that ships, or already in _send_unauthenticated for the ASGI path). The middleware is telling buyers "send me a Bearer token" while only accepting it on a non-standard header. That's the inconsistency this issue resolves.

Migration shape for adopters

Today (broken for spec-compliant clients):

BearerTokenAuth(
    validate_token=...,
    mcp_header_name="x-adcp-auth",
    mcp_bearer_prefix_required=False,
)

After this issue ships (works for both legacy and spec clients):

BearerTokenAuth(
    validate_token=...,
    legacy_header_aliases=["x-adcp-auth"],
)

Adopters who never had legacy clients (greenfield deployments) drop the kwarg entirely and inherit pure spec compliance.

Tests

The upstream test suite for BearerTokenAuthMiddleware already covers the single-header path. New tests:

  • Authorization: Bearer X accepted by default (no legacy_header_aliases configured)
  • Legacy alias accepted when Authorization: Bearer absent
  • Authorization: Bearer wins when both headers present with different tokens (or hand the divergence to a downstream audit hook — same shape as the existing dual-credential audit story).
  • Empty Authorization value falls through to legacy aliases (not auto-401).
  • Deprecation warning fires on header_name= construction.

Severity / urgency

Spec compliance. Not security-critical (the agent still rejects unauthenticated requests correctly via the legacy header path), but every adopter that exposes its endpoint to spec-compliant buyer clients sees gratuitous 401s on calls that carry valid credentials. Compliance probe surfaces it as security_baseline/probe_api_key failure.

Related

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions