Skip to content

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

Open
fkztw wants to merge 3 commits 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 3 commits 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.

@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.

fkztw and others added 2 commits May 25, 2026 12:08
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>
…ction

Refactor user identity injection to use proper HTTP middleware in the
transport layer, replacing the earlier round tripper implementation.

The ClaimInjectionMiddleware extracts the authenticated user's identity
from request context (populated by auth middleware) and injects it as
HTTP headers into requests forwarded to backend MCP servers:

  X-User-Sub:   the 'sub' claim (Google/OIDC user ID)
  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
implementing their own OAuth token validation or /introspect calls.

The middleware is wired into the HTTP transport chain in http.go, after
the existing oauth-token-injection middleware. When no identity is present
in context (anonymous request), the middleware is a no-op — no headers
are injected.

Also includes poc-dockerfile/Dockerfile.vmcp for building the vmcp image
with this patch applied via Google Cloud Build.

Signed-off-by: Frank Zheng <frank@tagtoo.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@fkztw fkztw force-pushed the feat/vmcp-inject-user-identity-headers branch from 333f308 to d0017f2 Compare May 25, 2026 04:09
@fkztw fkztw requested review from ChrisJBurns and blkt as code owners May 25, 2026 04:09
@fkztw fkztw force-pushed the feat/vmcp-inject-user-identity-headers branch from d0017f2 to 01f0513 Compare May 25, 2026 04:13
…tegy

Replace the hardcoded claimInjectionRoundTripper transport with a first-class
outgoing auth strategy (type: claim_injection). This addresses all four points
raised in PR review:

1. Anonymous mode: skip injection when identity.Subject is empty or "anonymous"
2. Architecture: backends opt-in via outgoingAuth.type: claim_injection instead
   of unconditional injection for every backend
3. Chain order: authRoundTripper (which calls the strategy) now runs after
   identityRoundTripper, so the strategy reads the fresh per-request identity
   from context rather than a captured session-time value
4. PII minimisation: email/name injection is opt-in via ClaimInjectionConfig.Claims;
   default is ["sub"] only

Configuration example:
  outgoingAuth:
    type: claim_injection
    claimInjection:
      claims: [sub, email]   # opt-in to forward email alongside sub

Co-Authored-By: Frank Zheng <frank@tagtoo.com>
@fkztw
Copy link
Copy Markdown
Author

fkztw commented May 25, 2026

Thanks for the detailed review @jhrozek! I've reworked the implementation to address all four points:

Changes

1. Anonymous mode (was: if identity != nil didn't exclude anonymous users)

The strategy now explicitly skips injection when identity.Subject is empty or "anonymous", which covers vmcp's unauthenticated mode (Subject="anonymous", Email="anonymous@localhost").

2. Architecture (was: unconditional injection for every backend)

Removed the hardcoded claimInjectionRoundTripper from the transport chain entirely. Backends now opt in via outgoingAuth.type: claim_injection:

outgoingAuth:
  type: claim_injection
  claimInjection:
    claims: [sub, email]   # opt-in; defaults to [sub] only

3. Chain order (was: comment described execution order backwards)

The strategy runs via authRoundTripper, which executes after identityRoundTripper has placed a fresh identity on the request context. The strategy reads from auth.IdentityFromContext(ctx) at Authenticate time — no more captured session-time identity.

4. PII data minimisation (was: email/name forwarded unconditionally)

ClaimInjectionConfig.Claims is opt-in. The default (empty list) injects only X-User-Sub. To also forward email:

claimInjection:
  claims: [sub, email]

Files changed

  • pkg/vmcp/auth/strategies/claim_injection.go — new strategy (+ 9-test suite)
  • pkg/vmcp/auth/types/types.goStrategyTypeClaimInjection constant + ClaimInjectionConfig struct
  • pkg/vmcp/auth/factory/outgoing.go — register strategy
  • pkg/vmcp/config/validator.go — add to valid types
  • pkg/vmcp/session/internal/backend/mcp_session.go — remove claimInjectionRoundTripper
  • docs/operator/crd-api.md — document ClaimInjectionConfig

All existing tests pass (3173 total).

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