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.
2. What the browser cookie contains¶
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¶
- Signed with
URLSafeTimedSerializer(SECRET_KEY)— see registry/auth/dependencies.py:17 and auth_server/session_store.py:106. session_idis generated bysecrets.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, andSecure=truewhenever the original request was HTTPS (auth-server readsX-Forwarded-Prototo detect load-balancer-terminated TLS). Domain attribute set fromSESSION_COOKIE_DOMAIN.
2.2 What the cookie does NOT contain¶
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.
2.3 Cookie size¶
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.
5.1 Cookie path: enhanced_auth¶
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):
- Resolve the session before anything else, to get
provider,id_token, andsession_id. - 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 ignoresSet-Cookie, thesession_idno longer resolves on the server. - Clear the local cookie with a
Set-Cookiecontaining the same name, path, and domain as the original (browsers ignore deletes that don't match the original attributes). - Redirect to the IdP's logout endpoint with
id_token_hint=<token>so the SSO session at the IdP also terminates. Ifid_tokenis missing (unusual; some flows omit it), proceed without the hint and rely on browser redirect alone. - 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_totalregistry_logout_id_token_hint_missing_totalregistry_logout_jwt_validation_failed_totalregistry_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.