Skip to content

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

@bokelley

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions