Home / Articles

A Dual OAuth Boundary for MCP Gateways: Keeping the User's Identity

MCP gateways shouldn't squash every call into one service account. Inbound validation plus an on-behalf-of OAuth exchange keeps each user's identity intact.

Publisher: REACTAPP.TOP11 min read2444 words

This article is published in English.

Sources: Solving the Identity Termination Problem in MCP Gateway Architectures

A gateway that sits between an MCP-driven agent and enterprise systems should hold two OAuth boundaries, so the person who made the request stays identifiable at every system the agent touches.

The failure

Walk through a typical failure. An employee asks an agent, via an MCP gateway, to file a ticket for a coworker. The gateway checks the employee against the corporate identity provider, validates the bearer token, and passes the request to the ITSM API, which creates the ticket. A month on, an auditor opens the ticket store and sees one name on everything the agent filed: the gateway's own application identity. The real requesters are missing from the record.

The same flaw repeats on every downstream system behind the gateway. The HR system credits every PTO request to the gateway's service account, the asset registry credits every device transfer to it, and the procurement workflow lists it as approver on every requisition. Identity ended at the gateway, and nothing further along can recover it.

MCP gateways have become the default bridge between LLM agents and enterprise systems of record, yet most production deployments leave one question vague: whose identity authorises each downstream call? Two anti-patterns dominate.

Terminated identity. After checking who is calling, the gateway talks to every backend as one shared service principal. Whatever it does to the IT system, the HR system and procurement is attributable to its service account and not to the person behind the request. Audit trails become useless and least privilege collapses. A misbehaving prompt can exercise the gateway's full reach against any backend it can touch.

Trust the client. The gateway accepts whatever bearer token arrives and forwards it untouched to downstream APIs, with no check of issuer, audience or scope. Token theft anywhere on the agent path then silently authorises arbitrary downstream calls.

Neither is acceptable for enterprise agentic AI, particularly under the Govern function of the NIST AI Risk Management Framework, which expects individual accountability to be preserved across automated actions on regulated systems.

The proposal

The design has two OAuth boundaries. At the client edge, the inbound one pulls out and verifies a delegated-access token, meaning an OAuth 2.0 bearer token (RFC 6749) issued to the calling user for the gateway's audience. The outbound boundary performs an on-behalf-of exchange (in the spirit of RFC 8693 token exchange) or, where the trust chain allows, direct delegated forwarding, and produces a token for the downstream audience that still carries the original user's identity.

Tool authorisation is decided at the boundary, from parsed token claims, and not buried inside tool implementations. That makes carrying identity forward the gateway's job instead of something every tool or backend must remember.

The pattern: Delegated Boundary OAuth

In short: the gateway carries two OAuth boundaries and not one, and the user's identity is the contract that passes between them.

The inbound boundary. Bearer-token extraction, issuer and audience validation and claims parsing all occur in one layer of the gateway, typically request middleware, or a FastMCP-style dependency injector, executing ahead of every tool function. This is where "who is calling, and are they allowed to use this tool?" gets answered, and no tool implementation may skip it. Scope-to-tool mapping is declarative: each exposed tool names the OAuth scopes it needs, and the boundary refuses to run it when the token lacks them. The refusal is a structured authorisation failure, not a 5xx from a permission check deep in the stack that burned compute on the way.

The outbound boundary. The downstream call is authenticated by a token that names the original user, never by the gateway's own service identity. Two mechanisms get there. An on-behalf-of exchange (Microsoft Entra OBO, an AWS Cognito identity-pool exchange, or a custom STS exchange) issues a fresh token addressed to the downstream API, with the human caller still as its subject. Direct forwarding, reserved for downstream services covered by an explicit trust agreement with the gateway, passes the inbound token along unchanged. The exchange is what makes propagation provable and not merely hoped for.

In between. Tokens are treated as state that lives for one request. Nothing is cached across requests, nothing is kept in a session, and no mutable state is shared. Every MCP tool call reads the token again from the live headers and forgets it once the request completes.

Implementation

Inbound: extracting the delegated token

The inbound boundary is a thin middleware that runs ahead of every tool function. In a Python MCP server built on a FastMCP-style framework, you depend on a CurrentHeaders injection and pull the bearer token with one small utility:

from typing import Optional

def extract_bearer_token(headers: dict) -> str:
    auth = headers.get("authorization") or headers.get("Authorization")
    if not auth or not auth.startswith("Bearer "):
        raise AuthError("missing or malformed bearer token")
    return auth[len("Bearer "):]

Next comes validation of that token against the issuer and audience the gateway expects, with the identity provider's published signing keys (JWKS):

from jose import jwt

def validate_inbound_token(token: str, jwks: dict, audience: str, issuer: str) -> dict:
    claims = jwt.decode(
        token, jwks,
        audience=audience,
        issuer=issuer,
        options={"require_exp": True, "require_iat": True},
    )
    return claims  # sub, scp/scope, exp, etc.

Validation has to happen in-process. Outsourcing JWT validation to a downstream proxy can work in narrow trust topologies, but it removes the gateway's ability to enforce scope-to-tool mapping before a tool's side effects run.

Outbound: the on-behalf-of exchange

The outbound boundary trades the inbound user token for one aimed at the downstream audience. That trade is what sets the pattern apart from naive forwarding, because the downstream API never sees the inbound token directly. The gateway calls the identity provider's token endpoint with a urn:ietf:params:oauth:grant-type:jwt-bearer request (Entra) or an assume-role-with-web-identity request (AWS), passing the inbound token as the subject assertion. The provider returns a new token for the downstream API whose sub claim is still the original user.

def exchange_obo(inbound_token: str, downstream_audience: str) -> str:
    response = requests.post(
        f"{IDP_TOKEN_ENDPOINT}",
        data={
            "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
            "client_id": GATEWAY_CLIENT_ID,
            "client_secret": GATEWAY_CLIENT_SECRET,
            "assertion": inbound_token,
            "scope": f"{downstream_audience}/.default",
            "requested_token_use": "on_behalf_of",
        },
        timeout=5,
    )
    response.raise_for_status()
    return response.json()["access_token"]

The exchanged token is then attached to the outbound HTTP call:

def call_downstream(downstream_token: str, path: str) -> dict:
    return requests.get(
        f"{DOWNSTREAM_BASE_URL}{path}",
        headers={"Authorization": f"Bearer {downstream_token}"},
        timeout=10,
    ).json()

Scope mapping and identity propagation

Tool functions declare their required scopes at registration, and the boundary enforces them before the tool body runs:

@tool(required_scopes=["ticketing.write"])
def open_ticket(headers: CurrentHeaders, summary: str, body: str) -> dict:
    token = extract_bearer_token(headers)
    claims = validate_inbound_token(token, jwks, audience=GATEWAY_AUD, issuer=ISSUER)
    enforce_scopes(claims, required=["ticketing.write"])  # central, not tool-local
    downstream_token = exchange_obo(token, downstream_audience=TICKETING_AUD)
    return call_downstream(downstream_token, path="/tickets")

Because scope mapping is declarative, it can be inspected. Anyone auditing the gateway gets every tool and its required scopes from the registry alone, with no need to read tool code.

The inbound boundary is the same across cloud suppliers. Only the outbound exchange wiring differs, and the two implementations below share one overall shape.

On AWS: API Gateway, Lambda and Cognito

On AWS the inbound boundary can be a custom Lambda authorizer on Amazon API Gateway. It verifies the token against an Amazon Cognito user pool and hands back a policy document plus a context map of parsed claims, so the MCP server's Lambda picks the claims out of event.requestContext.authorizer rather than verifying a second time. For the outbound side, sts:AssumeRoleWithWebIdentity takes the inbound token (or one issued by a Cognito identity pool) and yields temporary AWS credentials tied to the user's federated identity. Any API Gateway endpoint behind it, or an outside API that trusts Cognito JWTs, then sees the user in the sub claim.

A minimal Cognito and STS exchange in the gateway Lambda looks like this:

import boto3

def aws_obo(inbound_token: str, role_arn: str) -> dict:
    sts = boto3.client("sts")
    creds = sts.assume_role_with_web_identity(
        RoleArn=role_arn,
        RoleSessionName="mcp-gateway-obo",
        WebIdentityToken=inbound_token,
        DurationSeconds=900,
    )
    return creds["Credentials"]

The assumed role's trust policy names the Cognito user pool as federated principal and also checks the aud claim of the inbound token, which means only a token minted for this gateway can assume the role.

On Azure: the Entra on-behalf-of flow

On Azure the inbound boundary checks the incoming token against the gateway's Entra app registration, using the gateway's Application ID URI as audience. For the outbound hop it runs an Entra on-behalf-of exchange aimed at the downstream API's own app registration:

import msal

def entra_obo(inbound_token: str, downstream_scope: str) -> str:
    app = msal.ConfidentialClientApplication(
        client_id=GATEWAY_APP_ID,
        client_credential=GATEWAY_CLIENT_SECRET,
        authority=f"https://login.microsoftonline.com/{TENANT_ID}",
    )
    result = app.acquire_token_on_behalf_of(
        user_assertion=inbound_token,
        scopes=[downstream_scope],
    )
    if "access_token" not in result:
        raise AuthError(result.get("error_description", "obo exchange failed"))
    return result["access_token"]

The downstream API must accept tokens whose audience is its own Entra app registration. Its authorisation logic reads the oid (object ID) or upn (user principal name) claim to attribute the call to the original user. The pattern's shape is identical, and only the identity provider wiring changes.

Tying it to logging and evaluation

Propagated identity pays off most when it's recorded. If the gateway logs the inbound user on every tool invocation, an audit can trace an agent action to a person. The same field can ride along into evaluation records, tying test runs back to the individuals whose behaviour the system is responding to. Treat the identity that this pattern extracts as the first column of every audit and evaluation record the system produces.

When this pattern fits and when it doesn't

It fits when:

  • The gateway fronts systems where individual identity matters for authorisation, audit or compliance, such as IT service management, HR, finance and regulated workflows.
  • The enterprise identity provider supports a standard on-behalf-of exchange: Entra OBO, Cognito with STS web identity, or Auth0 and Okta token exchange.
  • Downstream APIs sit inside the same trust boundary as the gateway and can be set up to accept user-audience tokens.
  • The deployment falls under NIST AI RMF, SOX, HIPAA or similar governance that requires user-level accountability for automated actions.

It doesn't fit when:

  • Every downstream call is demonstrably not user-specific, for example read-only reference data with no user-attributable side effects.
  • A trusted downstream proxy already enforces full delegated semantics for the gateway, making the gateway's own work duplicative. Here the gateway builds just the inbound boundary plus a delegated-trust variant of the outbound side, forwarding directly under an explicit trust contract and accepting that the proxy is the real security boundary.
  • A downstream API refuses user-audience tokens and reworking it isn't on the table. Postpone the pattern until that changes. Falling back to a service principal "for now" should be a conscious call with the risk written down, never a default.

Operational considerations:

  • Latency. Every OBO exchange costs one extra trip to the token endpoint for each downstream call. Keep exchanged tokens around for as long as the inbound token stays valid, keyed by (sub, downstream_audience), and never across users.
  • Identity provider throttling. Token-endpoint quotas are not trivial. Provision headroom and track exchange-failure rates.
  • Refresh. Long agent sessions outlive a single token's lifetime. Plan for refresh-token rotation, or accept that an agent loop ends when its token expires, which is often the right security posture anyway.
  • Service-account fallback. Keep a service-account exchange path for truly user-less work such as health checks or scheduled background sync. Make it explicit, narrowly scoped and logged.

Where the pattern falls short

It assumes the identity provider supports a standard on-behalf-of exchange, and many older ones don't. In those environments the outbound side falls back to passing tokens straight through, covered by a written trust agreement with every downstream service. That's acceptable for backends inside the gateway's trust boundary but insufficient when another team or a third-party vendor runs the downstream service. The proper migration path is upgrading the identity provider, not settling permanently for forwarding.

It also assumes downstream services accept user-audience tokens. Many enterprise systems predate delegated authorisation and authenticate only against service principals. In that case the pattern must wait until the service is upgraded or an identity-aware proxy is introduced in front of it. Running it partially, with some calls user-attributed and others service-attributed, is the normal state during migration, but it should be tracked as technical debt with a closure date and not normalised.

Finally, it doesn't solve agent-as-actor accountability. Preserving the human's identity across the gateway hop is the right primary control, yet an LLM agent can still be prompted into actions the user never literally requested. The pattern guarantees attribution, not intent. Compensating controls carry that weight: human confirmation for high-impact actions, minimal scopes at the inbound boundary and per-call evidence logging.

Adoption checklist

  • Document the inbound contract (issuer, audience, supported scopes) as a versioned interface and manage it like a public API.
  • Implement scope-to-tool mapping as a declarative field at tool registration and not as ad hoc checks scattered through tool bodies.
  • Set up an on-behalf-of exchange with the enterprise identity provider for every downstream backend. Make OBO the default and keep direct forwarding for backends inside the gateway's explicit trust boundary.
  • Cache exchanged tokens within the inbound token's lifetime, keyed by user subject plus downstream audience, and never share them between users.
  • On every tool call, record both the inbound sub and the outbound sub, so propagation can be audited start to finish.
  • Allow one tightly scoped service-account path for work with no user, and require approval whenever it is used.
  • Record downstream APIs that reject user-audience tokens as architectural debt and work to retire that limitation.

As US enterprises and federal programmes adopt the NIST AI Risk Management Framework, its Govern function expects individual accountability to survive automated actions on regulated systems. Ending identity at the gateway runs directly against that, and invites audit findings in SOX, HIPAA and FedRAMP environments where every automated state change should trace to a named human.

Further reading

The foundations are RFC 6749 (the OAuth 2.0 Authorization Framework) and RFC 8693 (OAuth 2.0 Token Exchange) from the IETF. Vendor documentation covers the Microsoft identity platform's OAuth 2.0 On-Behalf-Of flow, the AWS STS AssumeRoleWithWebIdentity API reference and Amazon Cognito identity pools. NIST publishes the AI Risk Management Framework (AI RMF 1.0), and the MCP specification includes an authorization section worth reading alongside all of the above.