[{"data":1,"prerenderedAt":394},["ShallowReactive",2],{"blog-en-the-rfc-wants-a-token-nobody-holds":3,"header-blog-translations-/en/blog/the-rfc-wants-a-token-nobody-holds":391},{"id":4,"title":5,"author":6,"body":7,"date":373,"description":374,"draft":375,"extension":376,"image":377,"meta":378,"navigation":379,"path":380,"seo":381,"stem":382,"tags":383,"translationKey":389,"__hash__":390},"blog_en/blog/en/the-rfc-wants-a-token-nobody-holds.md","The RFC Wants a Token Nobody Holds","Patrick Hofmann",{"type":8,"value":9,"toc":366},"minimark",[10,27,30,35,67,74,77,87,93,97,107,117,123,127,136,287,293,299,306,310,316,329,333,336,339,342,345,362],[11,12,13,14,18,19,22,23,26],"p",{},"The Nest is a control-plane daemon that runs locally as its own Unix user — ",[15,16,17],"code",{},"_openape_nest",", ",[15,20,21],{},"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 ",[15,24,25],{},"~/.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.",[11,28,29],{},"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.",[31,32,34],"h2",{"id":33},"what-the-rfc-requires","What the RFC requires",[11,36,37,38,41,42,45,46,48,49,53,54,56,57,60,61,63,64,66],{},"RFC 8693 knows two tokens in the request: ",[15,39,40],{},"subject_token"," and ",[15,43,44],{},"actor_token",". The ",[15,47,40],{}," represents the party ",[50,51,52],"em",{},"for"," whom the new token is issued. The ",[15,55,44],{}," represents the party that ",[50,58,59],{},"acts",". In the spec text ",[15,62,40],{}," is required, ",[15,65,44],{}," optional.",[11,68,69,70,73],{},"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 ",[50,71,72],{},"both"," tokens. It holds the subject in hand and passes it on.",[11,75,76],{},"A typical request looks roughly like this:",[78,79,84],"pre",{"className":80,"code":82,"language":83},[81],"language-text","grant_type=urn:ietf:params:oauth:grant-type:token-exchange\n&subject_token=\u003Cthe token representing the user>\n&subject_token_type=urn:ietf:params:oauth:token-type:jwt\n&audience=https://timetrack.openape.ai\n","text",[15,85,82],{"__ignoreMap":86},"",[11,88,89,90,92],{},"Clean, as long as the one making the request actually owns the ",[15,91,40],{},".",[31,94,96],{"id":95},"my-caller-doesnt-own-it","My caller doesn't own it",[11,98,99,100,102,103,106],{},"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 ",[15,101,40],{}," slot the RFC requires isn't empty on the Nest side because I was lazy. It's empty because nothing ",[50,104,105],{},"should"," be there.",[11,108,109,110,112,113,116],{},"The information the ",[15,111,40],{}," would transport — ",[50,114,115],{},"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.",[11,118,119,120,122],{},"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 ",[15,121,40],{}," is replaced by a pointer in this case.",[31,124,126],{"id":125},"how-it-looks-now","How it looks now",[11,128,129,131,132,135],{},[15,130,40],{}," is optional when a ",[15,133,134],{},"delegation_grant_id"," is present. The delegator is derived from the grant, not from a token passed along.",[78,137,141],{"className":138,"code":139,"language":140,"meta":86,"style":86},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","if (!subjectToken) {\n  if (!delegationGrantId) {\n    throw badRequest(\"subject_token required\");\n  }\n  const grant = await grants.get(delegationGrantId);\n  // The \"subject\" here is not a token traveling through the agent,\n  // but a server-side pointer to the grant owner.\n  delegator = grant.delegator;\n}\n","ts",[15,142,143,166,185,213,219,251,258,264,281],{"__ignoreMap":86},[144,145,148,152,156,160,163],"span",{"class":146,"line":147},"line",1,[144,149,151],{"class":150},"s7zQu","if",[144,153,155],{"class":154},"sTEyZ"," (",[144,157,159],{"class":158},"sMK4o","!",[144,161,162],{"class":154},"subjectToken) ",[144,164,165],{"class":158},"{\n",[144,167,169,172,175,177,180,183],{"class":146,"line":168},2,[144,170,171],{"class":150},"  if",[144,173,155],{"class":174},"swJcz",[144,176,159],{"class":158},[144,178,179],{"class":154},"delegationGrantId",[144,181,182],{"class":174},") ",[144,184,165],{"class":158},[144,186,188,191,195,198,201,205,207,210],{"class":146,"line":187},3,[144,189,190],{"class":150},"    throw",[144,192,194],{"class":193},"s2Zo4"," badRequest",[144,196,197],{"class":174},"(",[144,199,200],{"class":158},"\"",[144,202,204],{"class":203},"sfazB","subject_token required",[144,206,200],{"class":158},[144,208,209],{"class":174},")",[144,211,212],{"class":158},";\n",[144,214,216],{"class":146,"line":215},4,[144,217,218],{"class":158},"  }\n",[144,220,222,226,229,232,235,238,240,243,245,247,249],{"class":146,"line":221},5,[144,223,225],{"class":224},"spNyl","  const",[144,227,228],{"class":154}," grant",[144,230,231],{"class":158}," =",[144,233,234],{"class":150}," await",[144,236,237],{"class":154}," grants",[144,239,92],{"class":158},[144,241,242],{"class":193},"get",[144,244,197],{"class":174},[144,246,179],{"class":154},[144,248,209],{"class":174},[144,250,212],{"class":158},[144,252,254],{"class":146,"line":253},6,[144,255,257],{"class":256},"sHwdD","  // The \"subject\" here is not a token traveling through the agent,\n",[144,259,261],{"class":146,"line":260},7,[144,262,263],{"class":256},"  // but a server-side pointer to the grant owner.\n",[144,265,267,270,272,274,276,279],{"class":146,"line":266},8,[144,268,269],{"class":154},"  delegator",[144,271,231],{"class":158},[144,273,228],{"class":154},[144,275,92],{"class":158},[144,277,278],{"class":154},"delegator",[144,280,212],{"class":158},[144,282,284],{"class":146,"line":283},9,[144,285,286],{"class":158},"}\n",[11,288,289,290,292],{},"The request the Nest sends carries its own token as ",[15,291,44],{}," and points to the grant instead of a subject:",[78,294,297],{"className":295,"code":296,"language":83},[81],"grant_type=urn:ietf:params:oauth:grant-type:token-exchange\n&actor_token=\u003Cthe Nest's own token>\n&actor_token_type=urn:ietf:params:oauth:token-type:jwt\n&delegation_grant_id=grant_\u003Cid-of-the-delegation-grant>\n&audience=https://timetrack.openape.ai\n",[15,298,296],{"__ignoreMap":86},[11,300,301,302,305],{},"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 ",[50,303,304],{},"where"," the proof \"on behalf of Patrick\" comes from. Not from a token the agent passes through. From a grant the IdP holds.",[31,307,309],{"id":308},"what-fell-away","What fell away",[11,311,312,313,315],{},"The ",[15,314,40],{},". And with it the assumption that the agent ever holds anything that represents me.",[11,317,318,319,321,322,324,325,328],{},"That's not strict RFC anymore. I also don't call it that. It's a deliberate deviation on exactly one axis — ",[15,320,40],{}," optional when ",[15,323,134],{}," is present, delegator from ",[15,326,327],{},"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.",[31,330,332],{"id":331},"the-point","The point",[11,334,335],{},"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.",[11,337,338],{},"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\".",[11,340,341],{},"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.",[343,344],"hr",{},[11,346,347],{},[50,348,349,350,357,358,361],{},"Code: ",[351,352,356],"a",{"href":353,"rel":354},"https://github.com/openape-ai/openape",[355],"nofollow","github.com/openape-ai/openape",", MIT-licensed. The token-exchange endpoint lives in the IdP, ",[15,359,360],{},"exchangeWithDelegation"," in the CLI auth layer, the Nest uses both via its own DDISA identity.",[363,364,365],"style",{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":86,"searchDepth":168,"depth":168,"links":367},[368,369,370,371,372],{"id":33,"depth":168,"text":34},{"id":95,"depth":168,"text":96},{"id":125,"depth":168,"text":126},{"id":308,"depth":168,"text":309},{"id":331,"depth":168,"text":332},"2026-05-09","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.",false,"md",null,{},true,"/blog/en/the-rfc-wants-a-token-nobody-holds",{"title":5,"description":374},"blog/en/the-rfc-wants-a-token-nobody-holds",[384,385,386,387,388],"OpenApe","AI Agents","Security","Infrastructure","Building in Public","the-rfc-wants-a-token-nobody-holds","S4sAieCG7sBpeS_0K2V8DLc-rB8eDUxeDKnI7C-bWrE",{"en":392,"de":393},"/en/blog/the-rfc-wants-a-token-nobody-holds","/de/blog/der-rfc-verlangt-ein-token-das-niemand-haelt",1779001887281]