Skip to content

Browser Login: Cookie + Session Flow

Audience: developers and operators who need to understand what happens between a user clicking "Login" and the registry returning their first authenticated page. This doc is provider-agnostic — the actual OAuth handshake with Entra / Okta / Keycloak / Cognito / Auth0 happens earlier and is documented in idp-provider-support.md. What follows here is everything that happens inside the platform once the IdP returns the user's identity.

Related docs: - authentication-design.md — the broader auth architecture (humans, JWTs, M2M). - idp-provider-support.md — provider-specific configuration and flows. - cookie-security-design.md — cross-subdomain cookie domain settings (still current; this doc layers on top of it). - session-flow-jwt-bearer.md — the equivalent doc for the JWT/Bearer M2M path.


1. The shape of a login

                    +-------------------+
   1. /login        |                   |
+----------------->| Registry frontend |
   user clicks      |                   |
                    +---------+---------+
                              |  302 Redirect
                              v
                    +-------------------+
   2. /oauth2/login |                   |
+----------------->|    auth-server    |
                    |                   |
                    +---------+---------+
                              |  302 Redirect
                              v
                    +-------------------+
   3. authorize     |  IdP (Entra /     |
+----------------->|  Okta / Keycloak /|
                    |  Cognito / Auth0)|
                    +---------+---------+
                              |  302 Redirect (with code)
                              v
                    +-------------------+
   4. /oauth2/      |                   |
      callback     |    auth-server    |
+----------------->|                   |
                    +---------+---------+
                              |  Set-Cookie: mcp_gateway_session=<id>
                              |  302 Redirect to registry /
                              v
                    +-------------------+
   5. authenticated |                   |
      page request |    Registry      |
                    |    (FastAPI)     |
                    +---------+---------+
                              |  reads cookie
                              v
                    +-------------------+
                    | session_store    |
                    | (MongoDB)        |
                    +-------------------+

The single observable thing the user's browser keeps from this flow is one HTTP-only signed cookie named mcp_gateway_session (configurable via SESSION_COOKIE_NAME). Everything else lives server-side.


After PR #1042 the cookie payload is only an opaque, signed session_id — a 64-character hex string identifying a row in the server-side oauth_sessions_<namespace> collection.

2.1 Wire format

mcp_gateway_session = itsdangerous_signed("<64-hex-session-id>")
  • Signed with URLSafeTimedSerializer(SECRET_KEY) — see registry/auth/dependencies.py:17 and auth_server/session_store.py:106.
  • session_id is generated by secrets.token_hex(32) (32 bytes of CSPRNG output).
  • The signature embeds the issue timestamp; the cookie is rejected if older than SESSION_MAX_AGE_SECONDS.
  • Cookie is HttpOnly, SameSite=Lax, and Secure=true whenever the original request was HTTPS (auth-server reads X-Forwarded-Proto to detect load-balancer-terminated TLS). Domain attribute set from SESSION_COOKIE_DOMAIN.

It does NOT carry username, groups, scopes, email, or id_token. Those used to live in the cookie pre-PR-#1042 (which is why Entra users with hundreds of AD groups could not log in — the cookie blew the 4 KB browser limit). They now live in the server-side session record.

A regression guard in tests/auth_server/unit/test_server.py asserts the cookie value stays under 512 bytes regardless of how many groups the user has. The assertion is the contract: anyone who tries to put inline user data back into the cookie will fail this test.


3. The server-side session record

The full session payload lives in MongoDB / DocumentDB. Collection name: oauth_sessions_<namespace> where <namespace> is the deployment's configured DocumentDB database (multi-tenant deployments use this for isolation).

3.1 Document shape

{
  session_id:        "<64-hex string>",       // Primary key, unique
  username:          "alice@example.com",      // From IdP claim
  email:             "alice@example.com",
  name:              "Alice Example",
  groups:            ["devs", "admins"],       // From IdP groups claim or Graph
  provider:          "entra",                  // Which IdP authenticated
  auth_method:       "oauth2",
  created_at:        ISODate(...),
  expires_at:        ISODate(...),             // TTL: doc auto-deleted at this time
  id_token_encrypted: <BinData>                // 12-byte nonce || AES-GCM ciphertext
}

3.2 Indexes

Two indexes, both created idempotently on first write (auth_server/session_store.py:80-98):

Index Fields Properties Purpose
ux_session_id session_id ASC unique Primary-key lookups; collision prevention.
ttl_expires_at expires_at ASC expireAfterSeconds=0 TTL — Mongo auto-deletes the document at the time stored in expires_at. Cleanup runs every ~60s.

3.3 At-rest encryption of id_token

The id_token_encrypted field is the only encrypted-at-rest field. Username, groups, and email are stored as-is — they were already client-visible in the old in-cookie payload, so encrypting them adds operational cost (debugging, audit) for no real threat-model gain. The id_token is different: it's a bearer credential and is sensitive enough to warrant defense against a DB-snapshot leak.

Encryption: AES-GCM, key derived from SECRET_KEY via HKDF-SHA256 with the fixed info string mcp-gateway-session-id-token-encryption. Domain separator ensures this key is distinct from the cookie-signing key derived from the same SECRET_KEY. See registry/auth/session_crypto.py.

Per-record nonce: 96-bit random from secrets.token_bytes(12). AES-GCM nonce-uniqueness is guaranteed under any reasonable session volume.

SECRET_KEY rotation invalidates all stored encrypted id_tokens; rotation requires a process restart of all auth-server and registry replicas (the AES-GCM cipher is cached in a process-wide singleton).

3.4 Lifecycle

Event What happens
OAuth callback succeeds auth-server inserts the document, gets session_id, returns a Set-Cookie to the browser.
Authenticated request registry resolves the cookie -> session_id -> find_one({session_id}). One indexed PK lookup per request.
TTL expiry Mongo's TTL monitor deletes the document automatically. Subsequent reads return None -> client redirected to /login.
Logout registry calls delete_session(session_id), then clears the cookie. The pre-delete order closes the cookie-replay window. See registry/auth/routes.py:217-226.
Suspected leak Operator drops the entire oauth_sessions_<namespace> collection — every session invalidated at once. See docs/operations/incident-response.md.

4. Provider-agnostic vs provider-specific

The cookie + session-store design above is identical for every IdP. The only thing that varies between providers is how groups get into the session document.

Provider How groups get populated
Cognito cognito:groups claim from the ID token.
Keycloak groups claim from the access token (configured via Keycloak group mapper).
Okta groups claim from the access token (requires Okta group claim configured at the app or authorization-server level).
Auth0 Custom claim namespaced under https://mcp-gateway/groups (configurable via AUTH0_GROUPS_CLAIM). Auth0 requires a Rule/Action to inject this claim.
Entra ID Inline groups claim OR the auth-server falls back to Microsoft Graph /me/memberOf when the ID token signals overage (hasgroups: true or _claim_names.groups present). Capped at 1000 group IDs. See auth_server/providers/entra.py:421-481.

Once the auth-server has groups in hand, the rest of the flow — session write, cookie set, registry resolve, scope derivation — is provider-blind.

The provider name is recorded on the session document in the provider field. Logout uses this to send the user back to the right IdP's logout endpoint with id_token_hint for proper SSO termination.


5. How the registry sees you on every request

Two paths reach _derive_user_context, the single source of truth for "is this user an admin and what can they see?" (registry/auth/dependencies.py:453-518). Cookie-based browser requests go through the cookie path. Header-based requests (typically nginx-proxied API calls with a JWT) go through the header path; that path is documented in session-flow-jwt-bearer.md.

Browser request
    |
    | Cookie: mcp_gateway_session=<signed-session-id>
    v
+----------------------------+
| FastAPI Cookie dependency  |
+-------------+--------------+
              |
              v
+----------------------------+
| signer.loads(cookie)       |  itsdangerous, validates signature + age
+-------------+--------------+
              |
              v
+----------------------------+
| _store_resolve_session(id) |  one find_one() against oauth_sessions_<ns>
+-------------+--------------+
              |
              v
+----------------------------+
| session_data dict:         |  username, groups, provider, id_token, ...
| {username, groups, ...}    |
+-------------+--------------+
              |
              v
+----------------------------+
| map_cognito_groups_to_     |  walks the scopes-config repo
| scopes(groups) -> scopes  |
+-------------+--------------+
              |
              v
+----------------------------+
| _derive_user_context(...)  |  -> ui_permissions, accessible_servers,
+-------------+--------------+     accessible_tools, is_admin
              |
              v
       request.state.user_context

Implementation: registry/auth/dependencies.py:521-559.

5.2 Failure modes

Failure What enhanced_auth returns What the user sees
Cookie missing 401 Redirect to /login.
Cookie present but signature invalid (tampered, wrong SECRET_KEY) 401 Redirect to /login.
Cookie expired (older than SESSION_MAX_AGE_SECONDS) 401 Redirect to /login.
Cookie present and valid, but session record missing (TTL'd, manually deleted, store outage) 401 Redirect to /login.
Session found but auth_method != "oauth2" 401 with "Session expired" detail Forces re-login via OAuth2.
Session found and OAuth2 200 with full user_context on request.state Page renders.

Critically, the resolver never raises 500 for transient store failures. A blip during a Mongo failover degrades to "redirect to login" — the next request after the store recovers will succeed.


6. CSRF token binding

Mutating endpoints (POST/PUT/DELETE) require an X-CSRF-Token header (or a csrf_token form field). The CSRF token is bound to the session_id, not the cookie value. This means CSRF tokens survive cookie format changes and are tied to the actual server-side session record. See registry/auth/csrf.py:23-38.

generate_csrf_token(session_id)  ->  signed token
validate_csrf_token(token, session_id)  ->  True / False

Skip behavior: if the request has no session cookie at all (e.g. it's a non-browser Bearer-token request), CSRF validation is skipped — those clients are not subject to CSRF attacks because they don't have a browser session. See registry/auth/csrf.py:114-158.


7. Logout

Sequence (driven by registry/auth/routes.py:189-288):

  1. Resolve the session before anything else, to get provider, id_token, and session_id.
  2. Delete the server-side record via delete_session(session_id). This closes the cookie-replay window — even if the cookie deletion below fails or the browser ignores Set-Cookie, the session_id no longer resolves on the server.
  3. Clear the local cookie with a Set-Cookie containing the same name, path, and domain as the original (browsers ignore deletes that don't match the original attributes).
  4. Redirect to the IdP's logout endpoint with id_token_hint=<token> so the SSO session at the IdP also terminates. If id_token is missing (unusual; some flows omit it), proceed without the hint and rely on browser redirect alone.
  5. Browser ends up at /login, ready to re-authenticate.

Operational visibility: four Prometheus counters track logout outcomes (registry/auth/routes.py:58-77):

  • registry_logout_id_token_hint_present_total
  • registry_logout_id_token_hint_missing_total
  • registry_logout_jwt_validation_failed_total
  • registry_logout_url_length_warning_total

8. Multi-replica considerations

Concern Behavior
Cookie signed by replica A, verified by replica B Works as long as both replicas share SECRET_KEY. The previous per-replica random fallback (removed in #1042) caused intermittent BadSignature under load-balanced traffic.
Session created by replica A, resolved by replica B Works because the session lives in MongoDB. Read-after-write consistency is handled by WriteConcern(w="majority") on insert and ReadPreference.PRIMARY_PREFERRED on read. See auth_server/session_store.py:74-78.
Logout on replica A while a stolen cookie hits replica B The delete is replicated within the read-after-write window. After that, all replicas see the missing record and return 401.
SECRET_KEY rotation Requires restart of every auth-server and registry replica (process-wide AESGCM cipher singleton). All active sessions invalidated.

9. Threat model (one-line summary per asset)

Asset Threat Mitigation
Cookie value (in transit) Interception Secure flag (HTTPS), HttpOnly (no JS access), SameSite=Lax (mitigates CSRF for top-level navigations).
Cookie value (at rest in browser) Replay after logout Server-side session deleted on logout; cookie alone does not prove identity.
Session record (in DB) DB-read leak id_token encrypted at rest with AES-GCM. Username/groups/email are not encrypted because they were already client-visible in the old in-cookie design.
SECRET_KEY Compromise Required at startup, no fallback, must be 32+ bytes from a CSPRNG (see unified-parameter-reference.md SECRET_KEY row).
Session collection Mass invalidation needed Drop the collection — every session invalidated at once. Documented in docs/operations/incident-response.md.

10. Common questions

Q: Why did this used to break Entra logins for admins? A: Pre-#1042, the cookie carried the entire session payload including the groups claim. Entra admins routinely have 200+ AD groups; the resulting cookie blew past the browser's 4 KB limit, was silently truncated/dropped, and login appeared to succeed but every subsequent request failed.

Q: Why store id_token server-side at all? A: SSO logout requires id_token_hint per OIDC RP-Initiated Logout (spec). Without it, the IdP terminates only the local session; the IdP-side session remains. Storing it server-side also keeps it out of the cookie (small) and out of any logged URL or header (safe).

Q: What happens if the user clears their cookies? A: Same as cookie-expired: redirect to /login. The server-side record stays until TTL — that's fine, it's just unreferenced and will be reaped.

Q: Can two browser tabs hold different sessions? A: Not for the same user on the same domain — cookies are domain-scoped, so both tabs see the same session_id and resolve to the same session. A user who wants two distinct sessions has to use different browsers or incognito mode.

Q: What if the registry restarts mid-request? A: The cookie is unaffected, the session record is unaffected. The next request resumes as normal.