Skip to content

[Server] Bind Streamable HTTP sessions to the authenticated principal #529

Open

Description

@chr-hertel

Currently a stateful StreamableHttpTransport session is resolved only by its Mcp-Session-Id header - Protocol::resolveSession() checks sessionManager->exists() and loads it with createWithId(), and DELETE goes straight to destroySession(). With AuthorizationMiddleware in front, every request is still authenticated with the caller's own token and scopes are not inherited, but the session doesn't know which principal created it. So a request with a different valid token and the same session ID is accepted into that session, DELETE included.

Session IDs are random UUIDs, so this isn't easy to hit - still, the spec's security best practices say servers SHOULD bind session IDs to user-specific information: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#session-hijacking (the draft has the same as "state handle hijacking").

Thanks @GEONWOOHAN for bringing this up.

Rough direction:

  • AuthorizationResult exposes a stable identity - issuer + subject rather than sub alone, maybe the client id as well
  • the transport passes that into session resolution
  • on initialize we store a fingerprint of it in the session, later POST/DELETE compare and answer 404 on mismatch

Open questions:
a) bind always when an identity is present, or opt-in via the builder?
b) does it belong into Protocol or rather the session manager / store, so a custom store can key on <principal>:<session id>?
c) what about validators that don't produce a subject (opaque tokens, API keys)?

Keeping the core unaware of OAuth would be nice, so probably a plain identity string rather than the auth result itself.

WDYT?

Activity

  1. added
    ServerIssues & PRs related to the Server component
    on Oct 5, 2026
  2. jarrettdustinqq commented on Oct 6, 2026

    @jarrettdustinqq

    The invariant I would preserve is: the authorization layer derives one opaque, stable session-binding ID; the session layer enforces it for every load and destroy without interpreting OAuth claims.

    That keeps Protocol OAuth-agnostic and also works for API keys/custom validators. For the built-in JWT path, a safe default looks like a canonical tuple derived from validated identity, e.g. issuer + subject, with client identity (client_id / azp) included only under an explicit, documented policy because those claims are not interchangeable across issuers. Persist a digest of that tuple, not raw claims. I would exclude the access token, expiry, and scopes so normal token refresh or scope changes do not orphan the session.

    One fail-closed edge case seems important: once a session is created as bound, a later request with no binding must be treated exactly like a mismatch. Otherwise a custom validator that omits identity, or middleware accidentally reordered later, becomes a downgrade path. If a deployment requires binding, initialize should fail when its resolver cannot produce an ID; legacy unbound sessions should require an explicit compatibility mode rather than happen implicitly.

    The current callback surface also suggests where enforcement has to land: processInput() can reach resolveSession(), but onSessionEnd / destroySession() carries only the UUID. A POST-only check in Protocol would therefore leave DELETE exposed. I would thread the opaque binding through both session resolution and teardown and make the manager/store boundary responsible—either binding-aware create/load/destroy operations or an atomic compound key such as <binding-digest>:<session-id>. That makes custom stores hard to bypass accidentally. Unknown, missing-binding, and mismatched-binding cases should remain indistinguishable externally (404).

    A compact regression matrix:

    • same issuer/subject/client after token rotation: POST and DELETE succeed;
    • same sub, different issuer: both fail;
    • same issuer/subject, different client: outcome follows the configured client-binding policy;
    • missing binding cannot access or delete an already bound session;
    • a custom non-OAuth validator can supply a stable opaque ID;
    • failures never reveal whether the UUID exists.

    AI assistance disclosure: ChatGPT was used to inspect the cited repository paths and draft this review. This is a source-level design analysis; I have not implemented or executed these tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ServerIssues & PRs related to the Server component

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions