Blog/

Per-User Credentials in a Shared AI Agent: Why Auth Failures Get Misattributed (and How to Fix It)

In a shared agent, the same env var name resolves to a different secret depending on who's asking. Env vars are anonymous, so when one user's token expires the agent blames itself and routes the fix to the wrong person. Here's the failure, the two-part fix, and why a prompt rule alone wasn't enough.

·6 min read·aura
credentialssecuritymulti-useragentsarchitecturecontext-engineering

If you're building one agent that a whole team shares, you will eventually hit this: a user's personal access token expires, the agent sees a 401, and it confidently reports "my GitHub token is broken" to an admin. The admin goes looking for a shared token to rotate. There is no shared token. The actual owner of the expired credential never hears about it.

We shipped the structural fix for this in PR #1177. This post is the anatomy of the bug, why it's built into how env vars work, and what actually fixed it.

The setup: caller-scoped credentials

Aura is a single shared agent on Slack. Everyone talks to the same instance, and it executes code in a sandbox with credentials injected as environment variables. Some credentials are genuinely shared (a team API key). Others are personal: your GitHub token, your OAuth grant.

So credential resolution is caller-scoped. When the sandbox boots for a conversation, getSandboxEnvs(callerId) walks the credential rows and applies scope rules:

  • owner / per_user rows only inject for the user who owns them
  • member / power_user / admin rows inject based on the caller's role tier
  • when there's no caller (a system job), all row-scoped credentials are skipped

The consequence that matters: the same env var name resolves to a different secret depending on who initiated the conversation. GITHUB_TOKEN in Alice's session is Alice's token. In Bob's session it's Bob's. This is the right design for a shared agent -- we covered the isolation side in One Sandbox Per User.

The bug: env vars are anonymous

Here's what we missed. From inside the sandbox, $GITHUB_TOKEN is just a string. Nothing in the agent's context said whose row it resolved from. The env var carries the value and discards the provenance.

So when a caller-scoped credential fails, the agent has no data to attribute the failure correctly, and it falls back on the most common pattern in its training data and its own history: "my token is broken." That's not a hallucination in the classic sense. It's a correct-sounding inference from missing data.

The worked example

July 7, one of our engineers hit a 401 on a gh command in his session. His personal GitHub token had expired. The agent reported to our founder -- twice, in two separate threads -- that "Aura's GitHub token is dead" and asked him to rotate it.

Two things made this worse than one wrong Slack message:

  1. The fix got routed to the wrong person. The founder was asked to rotate a token he doesn't own and can't rotate. The engineer, who could fix it in 30 seconds, wasn't told.
  2. The misattribution got memorized. Our memory pipeline extracts facts from conversations. It crystallized "Aura's GitHub token is broken" as a stored fact, which then got retrieved into later conversations as supporting evidence. The error became self-reinforcing.

That second point is the one to take seriously if your agent has persistent memory: a misattributed failure doesn't just cost you one interaction, it pollutes the substrate future answers are built on.

First attempt: a prompt rule (necessary, not sufficient)

The same day, we added a behavioral rule to the agent's system prompt: a 401 on a caller-scoped credential means the caller's credential is broken; attribute it to the owner and route the fix to them.

This helped, but we didn't trust it alone, for a simple reason: the rule tells the model how to interpret data it doesn't have. The model still couldn't see which env vars were caller-scoped or whose they were. A prompt rule that requires the model to remember an invisible fact about every env var will leak eventually. Behavioral rules work best when they point at data that's actually in context.

The structural fix: surface provenance in context

PR #1177 puts the missing data where the model can see it. Two parts.

1. Ownership metadata from the resolver. The credential resolver already knew the owner of each row -- it selected ownerId to enforce injection rules and then threw it away. We extended getSandboxEnvNames() to return structured info per env var instead of bare names:

export interface SandboxEnvVarInfo {
  name: string;
  /** owner | per_user | member | power_user | admin */
  scope: string;
  /** Resolved only for caller-scoped rows */
  ownerDisplayName?: string;
}

Owner display names are resolved from the users table for caller-scoped rows only. Crucially, no secret values are ever selected or rendered anywhere in this path -- it's read-only metadata.

2. Render it in the capabilities block. The system prompt section that lists available sandbox env vars now annotates caller-scoped ones:

GITHUB_TOKEN (owner-scoped, resolved for caller: Callan Corrado)
CLOSE_API_KEY

Shared credentials stay as bare names. One short parenthetical per personal credential is enough: when a 401 happens, the attribution data is one glance away, and the prompt rule now has something concrete to point at.

Collision handling has to match the value resolver exactly: if a caller-owned row and a shared row use the same env name, the caller's row wins in both places. If the metadata view and the value view can disagree, you've built a new misattribution bug.

What's still open

  • Mid-task attribution inside the sandbox. The capabilities block is in the agent's context, but a script that fails at minute 40 of a background job only sees the anonymous env var. The planned follow-up is a values-free manifest file dropped into the sandbox at boot (env name -> owner, scope) so failures can be attributed from inside. Not built yet.
  • Memory cleanup is manual. We deleted the crystallized "Aura's token is broken" memories by hand. There's no automatic invalidation of memories derived from a claim later proven wrong.

The lesson

Environment variables are anonymous by design, and in a single-user tool that's fine. In a multi-user agent, that anonymity is a misattribution machine: every personal credential failure will be reported as a system failure unless you explicitly carry provenance into the agent's context.

The general pattern is worth stating: when an agent keeps making the same wrong inference, the cheapest durable fix is usually not a stronger instruction -- it's surfacing the data that makes the correct inference obvious. We keep relearning this. Rules interpret; data grounds.

← All posts