Delegated access (AI agents)
Motivation
When an AI agent acts on behalf of a user, the worst design is also the most common one: handing the
agent the user’s own token. That token grants everything the user can do, for as long as it lives, with no
way to tell “the user did this” from “the agent did this”.
With the laravel-iam-agents module on your IAM server,
the agent instead exchanges the user’s token (OAuth 2.0 Token Exchange, RFC 8693)
for a short-lived, non-refreshable delegated token that carries BOTH identities:
{ "sub": "user:42", "act": { "sub": "agent:01J8XKQ0V2" },
"scope": "orders:read", "pds_dgr": "dgr_01J9…", "typ": "delegated+jwt" }
This client is the enforcement half (the PEP): it recognizes such tokens inbound, verifies them the
only safe way, and decides on the strict intersection — the action passes only if both the user
and the agent are allowed. Never the union, always fail-closed.
Inbound: recognize and verify
Two collaborators, both container-resolved:
Support\DelegatedBearerInspector— detects a delegated bearer by itstyp: delegated+jwtheader
or anactclaim. A malformed delegated token throws — it never silently degrades into a
single-subject token (that would let an agent impersonate its user on a legacy code path).Auth\DelegatedTokenVerifier— introspection-mandatory: the authoritative view of a delegated
token comes from the server’s/oauth/introspect, which also checks that the delegating user’s session
is still alive. Local claim parsing is only a hint; the server is the truth.
A resource server that trusts local signature checks alone will keep honouring a delegated token for its
whole TTL even after the user revoked the grant or logged out. Introspection makes revocation land at the
next request, not at token expiry.
Decide: the intersection rule
use Padosoft\Iam\Client\Facades\Iam;
$decision = Iam::checkDelegated(
$user, // the delegating subject (sub)
['agent:01J8XKQ0V2'], // the act chain, outermost actor first
'orders:read',
['delegation_grant_id' => 'dgr_01J9…'], // from the token's pds_dgr claim
);
if ($decision->granted()) {
// both the user AND the agent are allowed — and the grant is still active
}
Under the hood the request reaches the PDP’s checkDelegated: user check ∧ agent check ∧ grant
still active. A suspended agent, a revoked grant, or a user-side deny each veto the whole decision
(deny-overrides on every layer). Iam::canDelegated(...) is the boolean shorthand.
Protect an agent-facing route
Route::get('/orders', ListOrders::class)
->middleware('iam.can.delegated:app:orders.read');
The iam.can.delegated middleware:
- requires a bearer, verifies it as delegated (a plain user token here is a
401— human routes use
iam.can, agent routes use this; never ambiguity); - decides on the intersection, threading the token’s
pds_dgrasdelegation_grant_id; - on pass, exposes the delegation to your controller and hydrates Laravel Context:
$delegation = $request->attributes->get('iam_delegation');
// ['sub' => 'user:42', 'actors' => ['agent:01J8XKQ0V2'], 'grant_id' => 'dgr_…', 'scopes' => [...]]
use Illuminate\Support\Facades\Context;
Context::get('iam_delegation'); // same shape — automatically attached to every log entry
Because it rides Laravel Context, the delegation follows the request
into every log line and every queued job (Context dehydrates/rehydrates itself) — no package
downstream needs to know delegation exists, yet the audit pivot queries (“everything agent X did, for
anyone”) join across packages on these fields for free.
Outbound: performing the exchange (when your app IS the agent)
The same package covers the client side of RFC 8693 for orchestrators and agent runtimes (e.g. flow-ai’s
DelegatedIdentityResolver): TokenExchanger — resolved from the container, authenticated with the same
private_key_jwt settings (http.client_id + http.private_key: an agent has one identity).
use Padosoft\Iam\Client\Auth\TokenExchangeFailedException;
use Padosoft\Iam\Contracts\Delegation\TokenExchanger;
use Padosoft\Iam\Contracts\Delegation\TokenExchangeRequest;
try {
$delegated = app(TokenExchanger::class)->exchange(new TokenExchangeRequest(
subjectToken: $userAccessToken, // the USER's token — held by the backend, never by the LLM
scopes: ['orders:read'], // down-scoping (must be ⊆ the delegation grant)
audience: 'mcp://crm-tools', // recommended for MCP tool servers (aud-scoped tokens)
));
$delegated->accessToken; // TTL ≤ 300s, non-refreshable
} catch (TokenExchangeFailedException $e) {
$e->error; // 'invalid_grant' (revoked / agent suspended / session dead) vs 'invalid_scope'
}
Deliberately no caching and no refresh: the re-exchange is the revocation freshness check — the
server re-verifies grant and user session on every call. Failures throw ($e->error carries the RFC
error code); a degraded or empty token is never returned. The exchange refuses insecure transport before
sending anything: the subject token and the signed assertion never travel in cleartext.
Delegated decisions are never cached
CachingDecider bypasses its cache whenever the request carries an actor chain. A delegated decision must
observe a revocation immediately — at agent volumes the extra round-trip is the price of the
kill-switch actually working. Plain user decisions keep their short-TTL cache, unchanged.
The three built-in guarantees
| Guarantee | Why it matters |
|---|---|
| Malformed delegated token throws | No downgrade path from “agent acting for user” to “just the user” |
| Introspection-mandatory verification | Revocation lands at the next request, session liveness included |
| Delegated decisions never cached | The admin/user kill-switch is real, not eventually-consistent |
See also
laravel-iam-agentsdocs — the server module’s own
site: glossary, the intersection rule, token lifecycle, consent verifiers, threat model, and every
RFC 8693 error explained (repo).- Cache decisions — why normal decisions cache and delegated ones don’t.
- Fail-closed authorization — the posture every piece above inherits.