Back to blog
·by Patrick Hofmann

The RFC Wants a Token Nobody Holds

I wanted to implement RFC 8693 token exchange cleanly. At exactly one spot that didn't work — not out of laziness, but because the RFC assumes a case my architecture deliberately doesn't have: that the caller holds the user's token. In the agent-to-agent-on-behalf-of-human case, nobody does.

OpenApeAI AgentsSecurityInfrastructureBuilding in Public

The Nest is a control-plane daemon that runs locally as its own Unix user — _openape_nest, HOME=/var/openape/nest, started by me, not by the agent. It's supposed to fetch tokens for an agent that the agent works with against a service provider. On my identity, but without my token ever coming into its reach. My DDISA token sits in ~/.config/apes/auth.json in my home. The Nest runs as a different user and can't read it. That's not an implementation detail, that's the entire purpose of this separation.

For token delegation there's a standard: RFC 8693, OAuth 2.0 Token Exchange. I sat down to build that to spec. At one spot that didn't work. Here's why.

What the RFC requires

RFC 8693 knows two tokens in the request: subject_token and actor_token. The subject_token represents the party for whom the new token is issued. The actor_token represents the party that acts. In the spec text subject_token is required, actor_token optional.

The canonical use case behind it: a confidential client already holds a signed user assertion — a pre-issued token representing the user — and wants to exchange it for a downscaled token for a downstream service. The caller in this model has both tokens. It holds the subject in hand and passes it on.

A typical request looks roughly like this:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the token representing the user>
&subject_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=https://timetrack.openape.ai

Clean, as long as the one making the request actually owns the subject_token.

My caller doesn't own it

The Nest holds its own token — it has its own DDISA agent identity. What it doesn't hold is mine. Not "it's cumbersome to obtain", but: it lies behind a filesystem boundary I deliberately drew. The subject_token slot the RFC requires isn't empty on the Nest side because I was lazy. It's empty because nothing should be there.

The information the subject_token would transport — this token acts on behalf of Patrick — still exists. It just lives elsewhere: in a delegation grant. A standing grant in which I allowed this agent in advance to act in my name. The grant is server-side, signed by the IdP, and explicitly contains who the delegator is. The agent can reference it, but not forge it and not derive it.

So the choice was concrete: either I put my token where the Nest can read it — and topple the isolation the whole construction rests on — or I accept that the RFC-prescribed subject_token is replaced by a pointer in this case.

How it looks now

subject_token is optional when a delegation_grant_id is present. The delegator is derived from the grant, not from a token passed along.

if (!subjectToken) {
  if (!delegationGrantId) {
    throw badRequest("subject_token required");
  }
  const grant = await grants.get(delegationGrantId);
  // The "subject" here is not a token traveling through the agent,
  // but a server-side pointer to the grant owner.
  delegator = grant.delegator;
}

The request the Nest sends carries its own token as actor_token and points to the grant instead of a subject:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&actor_token=<the Nest's own token>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&delegation_grant_id=grant_<id-of-the-delegation-grant>
&audience=https://timetrack.openape.ai

The effect is identical to what the RFC wants to achieve: a token, issued on me, for action through the agent, scoped to a target SP. What changes is where the proof "on behalf of Patrick" comes from. Not from a token the agent passes through. From a grant the IdP holds.

What fell away

The subject_token. And with it the assumption that the agent ever holds anything that represents me.

That's not strict RFC anymore. I also don't call it that. It's a deliberate deviation on exactly one axis — subject_token optional when delegation_grant_id is present, delegator from grant.delegator — and everything else stays RFC-conformant. Whoever has both tokens still drives the standard path. The delegation-grant-only path is an extension for a case the RFC didn't have in view.

The point

The RFC isn't wrong. It's written for confidential clients that hold a pre-signed user assertion — caller has both tokens. That's a reasonable world. It's just not mine.

Agent-to-agent-on-behalf-of-human is a different form. Strict RFC conformance would have forced me to put my token where the agent can reach it — exactly what the architecture is supposed to prevent. The deviation isn't a shortcut. It's the same security property, just expressed differently: not "the agent carries the subject carefully through", but "the agent doesn't carry the subject at all".

A spec that requires the user's token to travel through the agent is a spec that assumes you trust the agent. I don't trust it. The grant carries the delegation. The agent carries nothing.


Code: github.com/openape-ai/openape, MIT-licensed. The token-exchange endpoint lives in the IdP, exchangeWithDelegation in the CLI auth layer, the Nest uses both via its own DDISA identity.