Skip to content

feat(vmcp): inject user identity as HTTP headers into backend requests#5291

Open
fkztw wants to merge 1 commit into
stacklok:mainfrom
fkztw:feat/vmcp-inject-user-identity-headers
Open

feat(vmcp): inject user identity as HTTP headers into backend requests#5291
fkztw wants to merge 1 commit into
stacklok:mainfrom
fkztw:feat/vmcp-inject-user-identity-headers

Conversation

@fkztw
Copy link
Copy Markdown

@fkztw fkztw commented May 15, 2026

Summary

When vmcp forwards tool calls to backend MCP servers, the authenticated user's identity is now injected as HTTP request headers:

  • X-User-Sub: the sub claim from the authenticated token (set when a subject is present)
  • X-User-Email: the email claim (set only when non-empty)
  • X-User-Name: the name claim (set only when non-empty)

Motivation

When vmcp acts as an aggregating gateway, it validates the user's Bearer token via the configured incoming authentication strategy (OIDC, anonymous, or an embedded auth server). Backend MCP servers receive the forwarded request but currently have no information about which user initiated the call — only that vmcp accepted the request.

This makes it difficult for backends to:

  • Enforce per-user authorization at the application layer
  • Apply user-specific filtering (e.g. row-level access in a database)
  • Emit audit logs that attribute actions to a user

Injecting identity claims as request headers is a common pattern in API gateway architectures — see nginx auth_request propagation, Envoy ext_authz response headers, Google IAP X-Goog-Authenticated-User-Email, and AWS API Gateway request-context identity fields.

Implementation

A new claimInjectionRoundTripper is added to the per-backend transport chain in createMCPClient(), placed after the existing identityRoundTripper:

auth → identity context propagation → claim header injection → (size limit) → base transport

The tripper reads the *auth.Identity already attached at client-creation time and sets headers for non-empty claim values. When no identity is configured (e.g. anonymous mode without a populated identity), it is a no-op and the original request is forwarded unchanged.

The forwarded request is cloned before mutation; the caller-supplied request and its headers are not modified.

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Four new tests cover claimInjectionRoundTripper in roundtripper_test.go, following the same patterns as the existing identityRoundTripper tests:

  • All three fields injected when present
  • Email/name omitted from headers when empty
  • Empty subject does not inject X-User-Sub
  • Original request is cloned (not mutated)

Manual verification with a backend stub confirmed the expected headers reach the downstream service.

API Compatibility

  • This PR does not break the v1beta1 API.

Changes

File Change
pkg/vmcp/session/internal/backend/mcp_session.go Add claimInjectionRoundTripper and wire it into createMCPClient() transport chain
pkg/vmcp/session/internal/backend/roundtripper_test.go Add 4 tests for claimInjectionRoundTripper

Does this introduce a user-facing change?

Yes. Backend MCP servers connected via vmcp will now receive X-User-Sub, X-User-Email, and X-User-Name HTTP headers containing the authenticated user's identity claims. Servers that do not read these headers are unaffected.

When vmcp forwards tool calls to backend MCP servers, the authenticated
user's identity (sub, email, name) is now injected as HTTP request headers:

  X-User-Sub:   the sub claim from the authenticated token
  X-User-Email: the email claim (when present)
  X-User-Name:  the name claim (when present)

This allows backend MCP servers to identify the calling user without
needing to implement their own OAuth token introspection. Servers can
simply read these headers, which are set by the vmcp gateway after it
validates the Bearer token.

The injection is implemented as claimInjectionRoundTripper, added to the
transport chain in createMCPClient() after the existing identityRoundTripper.
When no identity is present in context (e.g. anonymous mode), no headers
are injected — the tripper is a no-op.

Signed-off-by: Frank Zheng <frank@tagtoo.com>
@fkztw fkztw force-pushed the feat/vmcp-inject-user-identity-headers branch from 0f894e9 to 333f308 Compare May 15, 2026 09:04
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things on the new claim-injection roundtripper — none of these are necessarily blockers on their own, but the anonymous-mode and chain-order ones should at least get addressed before this lands.

base = &identityRoundTripper{base: base, identity: identity}
// Inject user identity as HTTP headers so backend MCP servers can read
// X-User-Sub / X-User-Email without needing their own /introspect calls.
if identity != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commit message says "When no identity is present in context (e.g. anonymous mode), no headers are injected" — but anonymous identity isn't nil. pkg/auth/anonymous.go builds a real *Identity with Subject="anonymous", Email="anonymous@localhost", Name="Anonymous User". So this if identity != nil guard passes in anonymous mode and the backend ends up with X-User-Sub: anonymous, etc.

Worth fixing one way or the other — either gate explicitly here (e.g. add an IsAnonymous() helper on *Identity and check !identity.IsAnonymous()), or update the commit message. The combination of implicit network trust + anonymous user looking like a real principal at the backend is the bit that worries me.

// Inject user identity as HTTP headers so backend MCP servers can read
// X-User-Sub / X-User-Email without needing their own /introspect calls.
if identity != nil {
base = &claimInjectionRoundTripper{base: base, identity: identity}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vmcp already has a per-backend outgoing-auth strategy registry — see the constants in pkg/vmcp/auth/types/types.go (unauthenticated, header_injection, token_exchange, upstream_inject, aws_sts) and the strategy resolved a few lines up at strategy, err := registry.GetStrategy(strategyName). This change is a parallel header-mutation path that runs unconditionally for every backend regardless of which strategy is selected.

Think about a setup with github-tools + atlassian-tools via upstream_inject and an internal-api via header_injection. With this code, the GitHub and Atlassian backends also get X-User-Sub / X-User-Email / X-User-Name, even though they're authenticating with a real upstream token and don't need (or really want) those headers — the headers describe the vmcp user, not the upstream service principal.

Could this be a new strategy variant instead? Either fold a fromClaim: source into header_injection, or add a separate claim_injection strategy. That way backends opt in and the claim → header mapping is configurable per backend.

slog.Debug("Applied authentication strategy", "strategy", strategy.Name(), "backendID", target.WorkloadID)

// Build shared transport chain: auth → identity propagation.
// Build shared transport chain: auth → identity propagation → claim injection.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up — this comment is the inverse of the actual execution order. http.RoundTripper chains wrap inward, so the outermost wrapper runs first on the outgoing request. As wired below, an outgoing request goes claimInjectionRoundTripperidentityRoundTripperauthRoundTripperhttp.DefaultTransport. So the real order is "claim injection → identity propagation → auth", not the other way round.

Either flip the order in the comment, or add a sentence clarifying that wrap order is the reverse of execution order.

if c.identity.Subject != "" {
cloned.Header.Set("X-User-Sub", c.identity.Subject)
}
if c.identity.Email != "" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Email (and Name a few lines down) are PII. As written, this sends them to every backend unconditionally whenever an identity is present. A backend that only needs sub for authorization still gets the user's email — which then very likely ends up in that backend's request logs.

If the feature stays in this form, defaulting to sub only and requiring explicit opt-in for email/name would be much better data-minimization. If it moves to a per-backend strategy (see the other comment), the claim set should be configurable there anyway.

@fkztw
Copy link
Copy Markdown
Author

fkztw commented May 20, 2026

Thanks jhrozek — this is exactly the kind of architectural feedback I was hoping for.

Agreed on all four points:

Comment 1 (anonymous mode, L300): Bug confirmed — anonymous.go builds a non-nil *Identity with Subject="anonymous" and Email="anonymous@localhost", so the current if identity != nil guard passes in anonymous mode and injects misleading headers downstream. I'll add an IsAnonymous() method on *Identity and gate injection on !identity.IsAnonymous().

Comment 3 (chain order comment, L288): Will fix — the comment is backwards. Outermost wrapper runs first on the outgoing request, so the actual execution order is claimInjection → identity → auth → transport, not the reverse.

Comments 2 + 4 (unconditional injection + PII, L301/L91): Both point to the same root cause — unconditional injection to all backends is the wrong default. I'd like to address both by moving claim injection from an implicit behavior wired in createMCPClient() to an explicit outgoing auth strategy (e.g. type: claim_injection alongside the existing header_injection, upstream_inject, etc.), with an optional claims field that lets operators select exactly which claims to forward:

outgoingAuth:
  type: claim_injection
  claimInjection:
    claims: [sub]          # default: sub only
    # claims: [sub, email] # explicit opt-in for PII fields

This way:

  • Backends using upstream_inject, token_exchange, or aws_sts are completely unaffected
  • PII (email, name) requires explicit opt-in — sub only by default
  • The pattern fits naturally into the existing strategy registry

Does that direction sound right to you? Happy to revise the PR accordingly.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants