mech.app

The mech.app newsletter

Agentic AI, minus the noise.

Get practical field notes on AI agents, automation, developer tools and security delivered to your inbox.

No spam. Unsubscribe anytime.

Security

Multi-User Agent Access Control: When OAuth Delegation Meets Shared Execution

How to map user identity to agent actions when one agent serves many users, and why single-user OAuth flows break down at the authorization boundary.

Source: alterauth.com
Multi-User Agent Access Control: When OAuth Delegation Meets Shared Execution

You build a Slack agent that pulls GitHub issues, creates Jira tickets, and sends Stripe invoices. Ten people use it. Each person signs in with their own account. The agent uses a single service credential.

Who is allowed to see which issues? Who can create invoices? When the agent makes 50 API calls in one user request, whose permissions apply?

This is the multi-user agent access control problem. OAuth was designed for one user, one session, one token. Agents break that model because the agent is the shared actor, but the authorization boundary must still enforce per-user permissions.

The Single-Credential Trap

Most internal agents start with a service account. You generate one GitHub token, one Slack bot token, one Stripe key. You paste them into .env and ship.

This works until someone asks: “Why can the sales agent see engineering repos?” or “Who approved that refund?”

The service account has admin scope. Every user inherits admin scope. You have no per-user policy, no audit trail that ties actions to individuals, and no way to revoke access for one person without rotating the shared credential.

The failure mode is silent. The agent works. The security boundary does not.

Identity Mapping: Agent vs. User

When an agent acts on behalf of a user, you need to answer three questions at every API call:

  1. Who initiated this request? The user who typed /agent do-thing in Slack.
  2. Who is making the API call? The agent, using its own credential.
  3. Whose permissions apply? The user’s, even though the agent holds the token.

Standard OAuth gives you a token scoped to one identity. If the agent uses its own token, you lose user context. If the agent uses the user’s token, you need to cache credentials for every user, handle refresh, and manage expiration while the agent is long-running.

The architecture choice splits into three patterns:

PatternToken OwnershipScope EnforcementAudit Complexity
Service accountAgent holds one tokenNone (admin everywhere)Low (agent is always the actor)
User token cachingAgent caches per-user tokensNative (API enforces)Medium (user is actor, agent is proxy)
Delegation proxyAgent requests scoped tokens on-demandPolicy layer interceptsHigh (agent + user + policy decision)

Service accounts are simple but insecure. User token caching is fragile (tokens expire, users revoke, refresh logic is stateful). Delegation proxies add latency but give you a policy decision point.

Delegation Tokens and Scope Inheritance

A delegation token is a credential the agent uses to act on behalf of a specific user for a specific scope. The agent does not see the user’s raw token. The authorization layer mints a short-lived token, injects it into the agent’s API call, and logs the decision.

The flow looks like this:

# Agent receives user command
user_id = slack_event["user"]
action = parse_intent(slack_event["text"])

# Agent requests scoped token from auth layer
grant = auth_client.request_grant(
    agent_id="sales-agent",
    user_id=user_id,
    scope=["github:repo:read", "stripe:invoice:create"],
    context={"channel": slack_event["channel"]}
)

# Auth layer evaluates policy
# - Is this agent allowed to act for this user?
# - Does this user have the requested scope?
# - Is this action within rate limits?

if grant.approved:
    # Agent makes API call with injected token
    response = requests.get(
        "https://api.github.com/user/repos",
        headers={"Authorization": f"Bearer {grant.token}"}
    )
    # Auth layer logs: user_id, agent_id, scope, target, status, latency
else:
    # Fail closed
    return "Access denied: user lacks github:repo:read"

The agent never holds the user’s long-lived credential. The auth layer enforces policy at call time. The audit log records both the user and the agent.

Audit Logs: Reconstructing Causality

When an agent makes 50 API calls in one user request, your audit log must answer:

  • Which user triggered this? The Slack user ID.
  • Which agent executed it? The agent’s identity.
  • What was the original intent? The command text or workflow ID.
  • Which calls succeeded? Status codes, latency, errors.
  • What data was accessed? Resource IDs, not payloads (payloads are too large).

A naive log records the agent as the actor. You lose user attribution. A better log records both:

{
  "timestamp": "2026-10-24T14:32:01Z",
  "agent_id": "sales-agent",
  "user_id": "U12345",
  "action": "token.proxy",
  "target": "api.github.com/user/repos",
  "scope": ["github:repo:read"],
  "status": 200,
  "latency_ms": 142,
  "grant_id": "gnt_7x92k",
  "workflow_id": "wf_abc123"
}

The grant_id ties all calls in one user request together. The workflow_id ties calls across multiple agent steps. You can reconstruct: “User U12345 asked sales-agent to list repos. The agent made 3 GitHub calls under grant gnt_7x92k. All succeeded.”

Token Refresh and Expiration

OAuth tokens expire. User sessions are ephemeral. Agents are long-running.

If the agent caches a user token and the user revokes it, the agent fails silently or keeps using a stale credential. If the agent requests a token on-demand and the user is offline, the agent cannot act.

The delegation proxy solves this by separating token lifetime from session lifetime:

  • The user grants the agent permission once (via OAuth or admin approval).
  • The auth layer stores a refresh token (encrypted, rotated, audited).
  • The agent requests a short-lived token for each action.
  • If the refresh token is revoked, the agent fails immediately.

The agent does not handle refresh logic. The auth layer does. The agent sees only short-lived tokens (5 minutes, 1 hour). If the user revokes access, the next token request fails.

Policy Engine: Default Deny

A multi-user agent needs a policy engine that evaluates every request. Default deny means the agent has no permissions until policy grants them.

Policy rules look like:

  • Agent sales-agent can act for users in group sales.
  • Agent sales-agent can call github:repo:read and stripe:invoice:create.
  • Agent sales-agent cannot call stripe:refund:create without human approval.
  • Rate limit: 1,000 calls per agent per hour.

The policy engine sits in the request path. The agent makes an API call. The call goes through the auth proxy. The proxy evaluates policy, injects the token, logs the decision, and forwards the request.

If policy denies the request, the agent sees a 403. The user sees “Access denied: insufficient scope.” The audit log records the denial.

Human-in-the-Loop for Sensitive Actions

Some actions are too risky to automate. Refunds, user deletions, production deploys. You want the agent to prepare the action but wait for a human to approve it.

The flow:

  1. Agent requests a sensitive action (e.g., stripe:refund:create).
  2. Policy engine flags it as requiring approval.
  3. Auth layer sends a Slack message to the approver: “sales-agent wants to refund $500 to customer X. Approve?”
  4. Approver clicks yes or no.
  5. If approved, the agent gets a one-time token for that specific call.
  6. If denied, the agent fails gracefully.

The agent does not block. It continues other work. When approval arrives, the agent resumes.

Deployment Shape

The delegation proxy sits between the agent and external APIs. It is not a sidecar. It is a centralized service.

Architecture:

  • Agent: Runs in your infrastructure (Lambda, ECS, Kubernetes). Makes API calls to the proxy, not directly to GitHub/Stripe/etc.
  • Auth proxy: Centralized service (hosted or self-hosted). Evaluates policy, mints tokens, logs decisions.
  • Policy store: Database of rules, scopes, rate limits. Updated by admins, not agents.
  • Credential vault: Encrypted storage for refresh tokens, API keys. Agents never see raw credentials.

The agent trusts the proxy. The proxy trusts no one. Every request is authenticated (agent identity), authorized (policy evaluation), and audited (tamper-evident log).

Failure Modes

Token refresh fails: The user revoked access or the refresh token expired. The agent sees a 401. The user sees “Re-authenticate to continue.” The agent does not retry with a stale token.

Policy evaluation times out: The proxy has a 500ms SLA. If policy evaluation takes longer, the request fails. The agent retries with exponential backoff. The audit log records the timeout.

Agent identity is compromised: An attacker steals the agent’s API key. The attacker can request tokens, but only for scopes the agent is allowed. The audit log shows anomalous behavior (e.g., 10,000 requests in 1 minute). Rate limits kick in. The agent key is rotated.

User permissions change mid-request: The agent starts a workflow with github:repo:read. Halfway through, an admin revokes the user’s GitHub access. The next token request fails. The agent logs an error. The workflow stops.

Technical Verdict

Use a delegation proxy when:

  • You have multiple users and one agent.
  • You need per-user permissions enforced at the API level.
  • You need an audit trail that ties actions to individuals.
  • You want to avoid caching user credentials in agent code.

Avoid it when:

  • You have one user (e.g., a personal assistant agent). Standard OAuth is simpler.
  • You trust the agent with admin scope (e.g., internal tooling where everyone is an admin).
  • You cannot tolerate the latency of a policy decision point (adds 50-200ms per call).

The delegation proxy is not free. It adds complexity, latency, and a new failure mode (the proxy itself). But it solves the identity mapping problem that breaks most multi-user agents at scale.