One Login Should Carry the License: Fixing the Second Auth Layer in Our MCP Gateway
There is a specific kind of bug that only shows up when something almost works. Not a crash, not a blank screen — a green light next to a single red one. You authenticated. You connected. You ran three tools and they all came back. And then the fourth tool, the one that should have been the easiest, says you aren't authenticated at all.
That happened today, and the red light turned out to be pointing at something worth writing down: how a single sign-on should carry a user's license all the way to the last tool call — and what breaks when it doesn't.
The setup: one login, a whole agent OS
AitherOS exposes its intelligence layer over MCP — the Model Context Protocol. Code search across millions of lines, delegation to specialist agents, graph-powered context, memory, deployment tooling. The MCP SaaS Gateway makes all of it reachable over HTTPS so an external coding agent can use it.
The authentication story is supposed to be simple. You run /mcp in Claude Code. A browser tab opens to our identity provider — idp.aitherium.com — and you sign in with an email code (an OIDC device flow). The callback fires, the tab says "Authentication successful. Connected," and Claude Code reports it reconnected to the gateway.
That's one login. The whole point of an OIDC device flow against your own identity service is that the resulting token is your identity — and it should carry your full entitlement: who you are, your tenant, your roles, your plan, your token balance. Authenticate once; the license rides along.
And mostly, it did. After the handshake we fired live calls. git_status returned real data. recall reached the memory graph. Tool listing was filtered to our tier. Everything that depended on the gateway knowing who we were worked.
Then we called get_account_info — the one tool whose entire job is to answer "who am I?" — and got:
{ "error": "Authentication required. No valid API key provided." }
You authenticated to ask who you are, and the answer was "you're not authenticated." That contradiction is the whole bug in one line.
The trace: where the identity got dropped
Our gateway does single sign-on correctly at the perimeter. Every request passes through an ASGI auth middleware that pulls the bearer token, verifies it against the right backend (ACTA for billing keys, AitherIdentity for OIDC/PAT tokens), and resolves a full TenantContext:
ctx = TenantContext(
user_id=user_id,
tenant_id=tenant_id,
tenant_slug=tenant_slug,
tier=tier, # free / pro / enterprise
roles=roles, # resolved from Identity
token_balance=..., # the entitlement
plan=plan,
)
That context is the license. It's resolved once, from the one login, and stashed in a per-request contextvar that every MCP handler can read. So far, so correct.
The break was downstream. The get_account_info tool didn't read that context. It did this instead:
def get_account_info() -> str:
headers = _caller_headers() # ← the MCP *service account*
resp = requests.get(f"{IDENTITY_URL}/identity/auth/me", headers=headers)
_caller_headers() returned the service's internal secret — the MCP server's own identity, not the caller's. It then asked AitherIdentity "who is the bearer of this token?" using a credential that wasn't a user token at all. Identity correctly answered: 401. The error string "No valid API key provided." lives in exactly one file in the whole codebase — that's how we knew the trace was right without a debugger.
So this was never a missing second authentication that the user needed to perform. It was a tool performing a second, wrong authentication — as the service account — while the user's real, already-verified identity sat unused one layer up. The license had been carried all the way to the door and then left on the doorstep.
The fix: thread the identity the gateway already has
The right design is the one the login already promised: one auth, and the entitlement flows to the tool. That meant propagating the perimeter-resolved identity through four layers so the last one could simply use it.
Layer 1 — the tool consumes the identity instead of re-authing. get_account_info now takes an injected _tenant_context and, when present, returns the already-resolved entitlement directly — no second round-trip, no second credential:
if _tenant_context and _tenant_context.get("user_id"):
return json.dumps({
"user_id": tc["user_id"],
"roles": tc["roles"],
"tier": tc["tier"],
"plan": tc["plan"],
"token_balance": tc["token_balance"],
"auth_source": "gateway_sso",
})
For the tools that genuinely must reach Identity's key store — listing, creating, rotating, revoking the caller's own API keys — we forward the caller's own bearer instead of the service secret, so the call acts as the user:
def _caller_headers(tenant_context=None):
if tenant_context and tenant_context.get("bearer_token"):
return {"Authorization": f"Bearer {tenant_context['bearer_token']}"}
# ... fall back to service account only in local/stdio mode
Layers 2–4 — carry the credential down. The auth middleware now retains the raw bearer for all caller types (previously only billing keys kept it); the gateway copies the entitlement and that bearer into the tool-execution context; and the executor injects them into _tenant_context. One login at the edge, the license intact at the leaf.
The part that mattered most: not trusting the wire
There's a sharp edge in a design like this. If a tool reads identity from an injected _tenant_context argument, what stops a caller from sending a _tenant_context in their tool-call arguments — {"_tenant_context": {"roles": ["super_admin"]}} — and impersonating an admin?
The answer has to be: that value is server-injected only, and any client-supplied one is destroyed before the tool ever runs. We made that unconditional:
# _tenant_context is a server-resolved value ONLY. Strip any client-supplied
# one before injection so it can't be forged to escalate roles.
arguments.pop("_tenant_context", None)
The server then re-injects the version built purely from the verified TenantContext. The identity a tool sees originates from the bearer-token verification at the perimeter, never from the request body.
We didn't take our own word for it. The change touches auth, entitlement, and token forwarding — exactly the surface where a confident fix can quietly open a hole — so it went through an adversarial security review before anything shipped. Four questions, each answered with file-and-line evidence:
- Can the forwarded bearer leak back to the caller? No — it's used only to build
Authorizationheaders; it never appears in a tool's returned JSON. - Can one request's token bleed into another? No — the bearer rides a contextvar with token-based set/reset in a
finally, isolated per request. - Can a caller forge
_tenant_contextto escalate? No — client-supplied values are stripped and overwritten server-side; identity comes from the verified token. - Does the bearer ever go anywhere but the user's own identity endpoints? No — only to AitherIdentity's
/meroutes, never to other services.
All four came back clean.
Why this is the interesting part of "selling MCP"
It would be easy to file this under "fixed a 401." But the seam it exposed is the one that decides whether an MCP gateway is a demo or a product.
A single sign-on isn't finished when the browser tab says "connected." It's finished when the last tool in the chain knows who you are and what you're entitled to — without asking you to authenticate again, and without a downstream component quietly substituting its own identity for yours. Entitlement that's resolved once and then dropped is worse than no entitlement, because it looks like it works right up until the tool that depends on it.
Get that right and the model is clean: one login carries the license, every tool honours it, and the entitlement you resolved at the edge is the same entitlement enforced at the leaf. That's the property you can actually sell.
One green light. One red light. The red one had the better story.