Skip to content

JWT / Bearer Token Login Flow

Audience: developers and operators who need to understand how the platform handles non-browser, programmatic access — CLI tools, AI coding assistants, AI agents, automated pipelines, and federated registries calling each other. This is the counterpart to session-flow-cookie-based.md and covers everything that does NOT use a browser session cookie.

Related docs: - session-flow-cookie-based.md — the browser/cookie counterpart. - authentication-design.md — broader auth architecture (humans, JWTs, M2M). - idp-provider-support.md — provider-specific configuration. - registry-auth-architecture.md — full validation pipeline for incoming requests.


1. The shape of a programmatic request

                            +--------------------+
                            |                    |
                            |  Client (CLI / AI  |
                            |  agent / federated |
                            |  registry / tool)  |
                            +---------+----------+
                                      |
                                      |   Header: X-Authorization: Bearer <token>
                                      |     (or standard Authorization: Bearer)
                                      v
                            +--------------------+
                            |                    |
                            |   nginx (gateway   |
                            |   reverse proxy)   |
                            +---------+----------+
                                      |
            +----- subrequest --------+
            |
            v
    +-------+----------+              |
    |  /validate on    |              |   2. nginx reads X-* response
    |  auth-server     |              |   headers, copies them onto
    +-------+----------+              |   the upstream request as
            |                         |   proxy_set_header X-User, etc.
            |  X-User, X-Username,    |
            |  X-Client-Id, X-Scopes, |
            |  X-Auth-Method, X-Groups|
            +-------------------------+
                                      |
                                      v
                            +--------------------+
                            |                    |
                            |   Registry / MCP   |
                            |   server upstream  |
                            +--------------------+

Key design property: the registry never validates the token itself. It trusts the X-* headers nginx injects, because nginx only injects them after a successful subrequest to auth-server's /validate. The trust boundary is "between auth-server and registry, traveling through nginx" — not "the client's HTTP request".


2. Where tokens come from

The platform validates four distinct token kinds. The auth-server's /validate endpoint dispatches based on inspection of the token, not on configuration.

2.1 IdP-issued JWT (the common case)

A user (or workload) obtained an OAuth2 access token from one of the configured IdPs:

IdP Flow Where the token came from
Cognito OAuth2 authorization-code or client-credentials Cognito user pool.
Keycloak OAuth2 authorization-code or client-credentials Realm token endpoint.
Okta OAuth2 authorization-code or client-credentials Okta authorization server.
Auth0 OAuth2 authorization-code or client-credentials Auth0 tenant.
Entra ID OAuth2 authorization-code or client-credentials Tenant /oauth2/v2.0/token. M2M variant uses roles claim instead of groups.

Validation: signature checked against the IdP's JWKS, issuer against the configured allowed-issuer list, audience against the registered client_id, expiration against current time. Provider-specific code lives under auth_server/providers/ — each provider's validate_token method returns a normalized dict shape.

2.2 Self-signed JWT (programmatic API token)

An interactive user clicks "Generate API Token" in the registry UI; the auth-server mints a JWT signed with HS256 over SECRET_KEY. Issuer mcp-auth-server, audience mcp-registry. These are used by CLI tools and AI coding assistants on behalf of the human user — same scopes as the issuing user.

Validation: HS256 signature against SECRET_KEY, issuer match, audience match, token_use=access, expiration. See auth_server/providers/entra.py:226-296 (present on every provider — the auth-server checks self-signed first, falls back to provider validation if the issuer does not match).

SECRET_KEY is required at process startup (see #1042) — there is no "development-secret-key" fallback.

2.3 Static registry API key

Operator-configured shared secret intended for trusted internal tooling (monitoring scripts, CI runners). Two configuration knobs:

  • REGISTRY_API_TOKEN — single-key form, simplest setup.
  • REGISTRY_API_KEYS — multi-key JSON form, each key has its own name + scopes (issue #779). Allows revoking one key without affecting others.

Activated by REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true. If enabled but no keys are set, static-token auth is disabled with a warning rather than failing open. See auth_server/server.py:129-149.

2.4 Federation static token

Used when one registry calls another in a federation deployment. FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true + FEDERATION_STATIC_TOKEN=<32+ char value>. Minimum length enforced (MIN_FEDERATION_TOKEN_LENGTH). The validating registry recognizes the token and synthesizes a federation-peer identity with federation-specific scopes. See auth_server/server.py:376-396 and auth_server/server.py:1846-1850.


3. The /validate endpoint

GET /validate on auth-server is the single entry point for token validation. nginx invokes it as an auth_request subrequest before forwarding the upstream request to the registry.

3.1 Headers it reads

Header Purpose
X-Authorization (preferred) or Authorization The bearer token. Custom X-Authorization is the canonical form to avoid clashing with downstream Authorization use.
Cookie If a session cookie is present and no Authorization header is set, the cookie path is used (rare; mostly browser SSE).
X-User-Pool-Id Cognito-specific — required to validate Cognito tokens against the right user pool.
X-Client-Id Cognito-specific — used to set the audience constraint.
X-Region Cognito-specific — defaults to us-east-1.
X-Original-URL The URL nginx is about to proxy to. Used for scope-vs-server access checks.
X-Body Optional captured request body. Used by tool-level access control (#1026) to inspect JSON-RPC method arguments.
Mcp-Session-Id Optional MCP session id — passed through to the audit logger.
X-Request-ID Optional client-supplied request id — passed through to logs.

3.2 Decision flow

+----------------------+
| /validate request    |
+----------+-----------+
           |
           v
+----------+-----------+
| Network-trusted?     |--- yes -->  Synthesize identity from network context.
| (env: trusted_ips)   |             Set X-* response headers.
+----------+-----------+
           | no
           v
+----------+-----------+
| Federation static    |
| token match?         |--- yes -->  Synthesize "federation-peer" identity.
+----------+-----------+
           | no
           v
+----------+-----------+
| Registry static API  |
| key match?           |--- yes -->  Use key's configured scopes/name.
+----------+-----------+
           | no
           v
+----------+-----------+
| JWT (self-signed     |
| OR IdP-issued)?      |--- yes -->  Provider-specific validation.
+----------+-----------+
           | no
           v
        401 Unauthorized

On any successful match, auth-server writes the X-* headers documented in section 4 below and returns 200. nginx's auth_request_set directives copy those response headers into shell variables, then proxy_set_header injects them onto the upstream request.

3.3 Failure semantics

  • 401 — token missing, signature invalid, issuer not allowed, audience mismatch, expired, or token_use != access.
  • 403 — token valid, but the requested URL is not in the user's allowed- servers / allowed-tools set.
  • 500 — only for unexpected internal errors (JWKS fetch failure, etc.).

The registry never sees a 401/403 directly from auth-server — nginx intercepts the subrequest result and returns the appropriate status to the client. The registry only ever sees requests where /validate returned 200.


4. The header surface auth-server -> nginx -> registry

These are the canonical X-* headers carried from auth-server's /validate response onto the upstream request:

Header Type Set by Read by Meaning
X-User string auth-server registry (legacy alias) Username. Set on every successful validation.
X-Username string auth-server registry Username. The canonical name; new code uses this. Both are set so old upstreams keep working.
X-Client-Id string auth-server registry OAuth client_id (or federation-static / static-key name for non-OAuth tokens).
X-Scopes string (space-separated) auth-server registry Pre-computed scopes for this principal. Format: "scope1 scope2 scope3". Empty string is allowed.
X-Auth-Method string auth-server registry One of: cognito, keycloak, okta, auth0, entra, self_signed, network-trusted, federation-static, or the static-key's configured method. Used by audit logging and by the unified user-context derivation.
X-Groups string (space-separated) auth-server registry IdP groups (for OAuth tokens) or roles (for Entra M2M). Empty if the principal has no groups.
X-Server-Name string auth-server registry The server the request was validated for (extracted from X-Original-URL).
X-Tool-Name string auth-server registry The tool name when the URL targets a specific tool (used by #1026 tool-level access control).

The registry consumes these in nginx_proxied_auth (registry/auth/dependencies.py:562-668). Both the cookie path and the header path eventually call _derive_user_context, the single source of truth for "is this user an admin and what can they see?"

4.1 Fallback when X-Auth-Method is absent

If X-Auth-Method is missing on a header-auth request (a misconfigured nginx, or a future provider whose /validate forgot to set it), the registry logs a warning and defaults to keycloak — see registry/auth/dependencies.py:626-637. This used to be silent; #1055 added the warning. The default is preserved for backward compatibility but operators are expected to fix the upstream config.

4.2 Why X-Groups, not synthesized from X-Scopes

Before #1042 the header path would synthesize groups from scope strings if X-Groups was missing. That heuristic produced different admin verdicts between the cookie path and the header path for the same user (#933). It's gone. Today, if X-Groups is missing, groups are simply empty and admin status is derived purely from the auth-server-computed scopes in X-Scopes.


5. The _derive_user_context contract

Both auth paths normalize their inputs to the same tuple (username, groups, scopes, auth_method, provider, client_id?) and call _derive_user_context (registry/auth/dependencies.py:453-518). The output is a dict used everywhere downstream:

{
    "username": "alice@example.com",
    "client_id": "abc-1234-...",
    "groups": ["devs", "admins"],
    "scopes": ["mcp-servers-unrestricted/read", ...],
    "auth_method": "entra",       # or self_signed, federation-static, ...
    "provider": "entra",
    "session_id": "...or None",
    "accessible_servers": ["server-a", "server-b"],
    "accessible_tools": {...},
    "accessible_services": [...],
    "accessible_agents": [...],
    "ui_permissions": {...},
    "can_modify_servers": True,
    "is_admin": True,             # mutating UI scope with "all" -> admin
}

Same input -> same output, regardless of how the user authenticated. This is the property the cookie + header symmetry guarantees.

Special case: auth_method == "federation-static" short-circuits to a no-access context. Federation static tokens use a separate routing path (peer-to-peer federation API), not registry-side scope derivation.


6. Provider-specific notes

6.1 Entra ID

  • M2M tokens use the roles claim instead of groups. The provider detects this and substitutes (auth_server/providers/entra.py:199-203).
  • Two valid issuers per token: https://login.microsoftonline.com/<tenant>/v2.0 (v2.0 endpoint) and https://sts.windows.net/<tenant>/ (v1.0/M2M). The validator accepts either.
  • Audience is checked against both <client_id> and api://<client_id> to handle Entra's two audience formats.
  • Group-overage handling (browser flow only — irrelevant here) is covered in the cookie-flow doc.

6.2 Cognito

  • Requires X-User-Pool-Id and X-Client-Id headers from nginx.
  • Validates against the user pool's JWKS endpoint.
  • cognito:groups claim populates X-Groups.

6.3 Keycloak / Okta / Auth0

  • Standard OAuth2/OIDC validation against the provider's JWKS.
  • Audience and issuer constraints from the per-provider config.
  • Auth0 requires a Rule/Action in the tenant to inject the groups claim (default claim name https://mcp-gateway/groups, configurable).

6.4 Self-signed (programmatic API tokens)

  • HS256 over SECRET_KEY. No JWKS fetch, no network call.
  • Validated before any IdP — auth-server inspects the token's iss claim; if it equals mcp-auth-server, self-signed validation runs and IdP validation is skipped.

6.5 Static keys

  • Constant-time string comparison against the configured value(s).
  • Identity synthesized from the key's name and configured scopes.

7. Audit logging

Every /validate call emits an audit-log event when the request targets an MCP server:

{
  "event": "mcp_access",
  "request_id": "...",
  "mcp_session_id": "...",
  "username": "alice@example.com",
  "auth_method": "entra",
  "client_id": "...",
  "server_name": "server-a",
  "tool_name": "search_logs",
  "duration_ms": 12,
  "outcome": "allowed" | "denied"
}

Stored in audit_logs_<namespace> with TTL. Operator export procedures will land under docs/operations/ per issue #1056.


Aspect Cookie flow JWT/Bearer flow
Where state lives Server-side in oauth_sessions_<ns> collection. Stateless — every request carries the full token.
What the client transmits One short signed cookie. The full token (typically 800-2000 bytes for OAuth tokens, less for self-signed).
Validation cost per request One indexed find_one() against MongoDB. One JWKS-cached signature check (no DB read).
Logout Delete the server-side record + clear the cookie. No logout — token expires on its own. To revoke before expiry, use the IdP's revocation endpoint or rotate the static-key value.
CSRF concerns Yes (mitigated by SameSite=Lax + CSRF token bound to session_id). No (no browser session = no CSRF surface).
Affected by SECRET_KEY rotation Yes — invalidates all sessions. Self-signed JWTs invalidated. IdP-issued JWTs unaffected (signed with IdP keys).
Multi-replica coordination Shared SECRET_KEY across replicas; session record visible to all. Stateless — every replica independently validates.
Threat model for token leak Server-side delete closes the replay window. Token replayable until exp. Mitigation: short TTLs + IdP revocation.

9. Common questions

Q: Why two header names — X-User and X-Username — for the same value? A: Legacy. X-User is the original; X-Username is the canonical name new code reads. Both are set for backward compatibility with older upstreams. The registry's nginx_proxied_auth reads X-Username or X-User.

Q: Why does /validate accept both X-Authorization and Authorization? A: Because some downstream MCP servers also use Authorization for their own purposes (e.g. an MCP server that proxies to GitHub needs to forward the GitHub token). Using X-Authorization for the gateway-level token avoids the clash. The standard Authorization is accepted as a fallback for clients that can't set custom headers.

Q: Can a client present both a cookie and a Bearer token? A: Yes. The header path takes precedence — nginx_proxied_auth checks headers first, falls back to cookies only if no X-User/X-Username is present. See registry/auth/dependencies.py:611-668.

Q: Does the JWT flow ever touch the oauth_sessions_<ns> collection? A: No. Tokens are stateless; that collection is only for browser sessions.

Q: What if auth-server is temporarily down? A: nginx's auth_request returns 502/504 to the client. The registry never sees the request. This is by design — it's a fail-closed posture.

Q: How are scopes resolved on every request — does that hit MongoDB? A: Yes. _derive_user_context calls resolve_scope_access(scopes), which walks the scopes-config repo. The result is cached per-request via the shared scope_repo factory. Hot paths could be cached longer if needed (observable via registry_session_store_resolve_total for the cookie path and per-server access metrics for the header path).