<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DarkEdges</title>
    <description>The latest articles on DEV Community by DarkEdges (@darkedges).</description>
    <link>https://dev.to/darkedges</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1396307%2F1b5a1a60-77b1-40cd-89e3-0cfb704caf5a.jpeg</url>
      <title>DEV Community: DarkEdges</title>
      <link>https://dev.to/darkedges</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/darkedges"/>
    <language>en</language>
    <item>
      <title>Missions, consent and three tokens: how the AAuth person server decides</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:12:50 +0000</pubDate>
      <link>https://dev.to/darkedges/missions-consent-and-three-tokens-how-the-aauth-person-server-decides-hj0</link>
      <guid>https://dev.to/darkedges/missions-consent-and-three-tokens-how-the-aauth-person-server-decides-hj0</guid>
      <description>&lt;p&gt;Part 2 ended with SearchCo answering &lt;code&gt;401 requirement=person-token&lt;/code&gt;: it knows which agent is calling, and now wants to know who for. This part covers the rest of a hop, and the mission that every decision is checked against.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;
        aauth-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      AAuth (draft-hardt-oauth-aauth-protocol-11) version of the Relay Biscuit lab, with a side-by-side comparison
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — AAuth network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml/badge.svg" alt="check"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Relay Biscuit lab&lt;/a&gt; rebuilt on &lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="nofollow noopener noreferrer"&gt;AAuth&lt;/a&gt;. It has the same operators, scenarios, budget rules and UI. Biscuit attenuation is replaced by AAuth's signed HTTP requests, person-server tokens and call chaining. Signatures, tokens and HTTP exchanges are real. Operators, work and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://aauth.demos.darkedges.com" rel="nofollow noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/aauth-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Faauth-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay AAuth playground showing the agent network, credit ledger, the approved mission with per-hop auth tokens, and the identity and receiver checks for SearchCo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;&lt;/strong&gt; for a side-by-side analysis of the two solutions.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8796" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8796&lt;/a&gt;&lt;/strong&gt;. The Biscuit lab uses 8795, so both labs can run side by side. On Windows, run &lt;code&gt;./start.ps1&lt;/code&gt;. The interface starts a demo run automatically.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm check   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; behaviour, spec-conformance, reference-client interop and debug formatting tests&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How the AAuth version works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each run creates fresh Ed25519 keys and a fresh set of origins, such as &lt;code&gt;https://search.&amp;lt;tag&amp;gt;.relay.example&lt;/code&gt;. Requests between parties are real &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt; objects routed in-process to that…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The mission
&lt;/h2&gt;

&lt;p&gt;Before any work starts, Alice's assistant proposes a &lt;strong&gt;mission&lt;/strong&gt; to her person server (PS). It is a short, signed POST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"# Research a topic&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Plan the work, research, search sources and run compute, billed to my organization."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Delegate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hand research tasks to Relay operators"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://planner.example"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"relay"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"payer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice-org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"https://search.example"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;description&lt;/code&gt;, &lt;code&gt;tools&lt;/code&gt; and &lt;code&gt;resources&lt;/code&gt; come from the draft. &lt;code&gt;relay&lt;/code&gt; is the lab's extension: who pays, how much, and which scopes the PS may issue to each company. The draft allows extra members, and section C.1.10 is explicit that &lt;code&gt;tools&lt;/code&gt; and &lt;code&gt;resources&lt;/code&gt; are a record of what the agent said it would do, not limits the PS has to enforce. The lab's PS enforces &lt;code&gt;relay&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consent is a deferred response
&lt;/h2&gt;

&lt;p&gt;Only Alice can approve a mission, and she isn't sitting inside the HTTP request. So the PS answers straight away with &lt;code&gt;202 Accepted&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;202&lt;/span&gt; &lt;span class="ne"&gt;Accepted&lt;/span&gt;
&lt;span class="na"&gt;AAuth-Requirement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requirement=interaction; url="https://ps.example/interact"; code="NTAY-S7A3"&lt;/span&gt;
&lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://ps.example/pending/UAoyqZ00s8O-skbMstbB7w&lt;/span&gt;
&lt;span class="na"&gt;Retry-After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="na"&gt;Cache-Control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-store&lt;/span&gt;

{"status":"pending","requirement":"interaction","code":"NTAY-S7A3","timeout":600}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assistant shows Alice the URL and the code. The code is eight Crockford base32 characters, so it is easy to read aloud and compare on screen. Meanwhile the assistant polls the &lt;code&gt;Location&lt;/code&gt; with signed &lt;code&gt;GET&lt;/code&gt; requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /pending/…  → 202 {"status":"interacting"}   Alice has opened the page
GET /pending/…  → 200 { s256, mission, person_tokens }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PS checks every poll is signed by the agent that made the original request, and answers &lt;code&gt;410 Gone&lt;/code&gt; once the result has been collected. If Alice declines, the poll returns &lt;code&gt;403 denied&lt;/code&gt; and the assistant stops.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;200&lt;/code&gt; carries the approved mission as base64url bytes, its &lt;code&gt;s256&lt;/code&gt; (the SHA-256 of those exact bytes), and a person token for each resource the mission named. The assistant checks the hash against the bytes before using it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;s256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mission bytes do not match s256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here on, every token in the run carries &lt;code&gt;mission_s256&lt;/code&gt;. That hash is how the PS, and anyone else who fetches the mission, knows which approval a request belongs to.&lt;/p&gt;

&lt;p&gt;In the lab, Alice's browser session is simulated: she opens the URL and approves (or declines, if you pick that option). The &lt;code&gt;202&lt;/code&gt;, the code and the signed polls are real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token one: the person token
&lt;/h2&gt;

&lt;p&gt;The person token says who the agent is acting for, at one specific resource. Here is the one ResearchCo presents to SearchCo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ed25519"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aa-person+jwt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ps-1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://ps.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dwk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aauth-person.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://search.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0_I1miUFWgU1z6VmwgCDnI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cnf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jwk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ResearchCo's signing key"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mission_s256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UBwsblTo4WYic7rJjRutN63H4ftxVCWS-ll8eehE6jE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pt-4c1eb61e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1792880414&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sub&lt;/code&gt; is directed.&lt;/strong&gt; The PS gives Alice a different identifier at each resource, so SearchCo and ComputeCo can't match her up between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It authorizes nothing.&lt;/strong&gt; A person token has no scope. It only identifies the person, so the resource can say what it needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Token two: the resource token
&lt;/h2&gt;

&lt;p&gt;ResearchCo repeats its call with the person token. SearchCo verifies it, then answers with a second challenge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="ne"&gt;Unauthorized&lt;/span&gt;
&lt;span class="na"&gt;AAuth-Requirement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requirement=auth-token; resource-token="eyJhbGciOiJFZDI1NTE5IiwidHlwIjoiYWEtcmVzb3…"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resource token is SearchCo's request to the PS, addressed to it (&lt;code&gt;aud&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://search.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://ps.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0_I1miUFWgU1z6VmwgCDnI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice-org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent_jkt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"z6mEDnxVP4k0Op6Yn9LN7-DS8ucrLW0m3MXBj5eqnE4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"presented_jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pt-4c1eb61e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mission_s256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UBwsblTo4WYic7rJjRutN63H4ftxVCWS-ll8eehE6jE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1792879815&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It names the scope SearchCo needs, the billing account the caller asked for, the thumbprint of the caller's key (&lt;code&gt;agent_jkt&lt;/code&gt;) and the person token it was issued against (&lt;code&gt;presented_jti&lt;/code&gt;). It lives for five minutes. The lab builds it with one library call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resourceToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createResourceToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;presentedToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;agentJkt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kid&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Token three: the auth token
&lt;/h2&gt;

&lt;p&gt;ResearchCo sends the resource token and the person token to the PS. This is where authorization is decided. The PS first checks the resource token is genuine and matches what was presented (section 6.7.2 of the draft):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the resource token is signed by the resource named in its &lt;code&gt;iss&lt;/code&gt;, and addressed to this PS;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent_jkt&lt;/code&gt; is the key signing this request;&lt;/li&gt;
&lt;li&gt;the person token was issued by this PS, to the resource that issued the resource token, bound to the same key;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;presented_jti&lt;/code&gt;, &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;mission_s256&lt;/code&gt; and &lt;code&gt;tenant&lt;/code&gt; all match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;mission_s256&lt;/code&gt; check matters more than it looks. Without it, an agent could ask a resource for a token, strip the mission hash, and get an auth token that isn't bound to any approval. The lab has a test for exactly that.&lt;/p&gt;

&lt;p&gt;Then the PS applies its own policy to the mission. In the lab, that means two checks from the &lt;code&gt;relay&lt;/code&gt; extension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;resourceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;IDENTITY_SCOPES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`relay.scopes[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] ∋ "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PS policy · lab extension&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`account "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;resourceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" == relay.payer "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;resourceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PS policy · lab extension&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If both pass, the PS mints the auth token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aa-auth+jwt"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://ps.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://search.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0_I1miUFWgU1z6VmwgCDnI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice-org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice@alice-org.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mission_s256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UBwsblTo4WYic7rJjRutN63H4ftxVCWS-ll8eehE6jE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cnf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jwk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ResearchCo's signing key"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1792880414&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PS released &lt;code&gt;email&lt;/code&gt; because SearchCo asked for the &lt;code&gt;email&lt;/code&gt; scope. The token can't outlive the agent token, the person token or the mission.&lt;/p&gt;

&lt;p&gt;ResearchCo presents it on the third call, and SearchCo checks it against its own policy: does it accept work for this person, does the scope include the action, does it offer that action, and does the account match the payer in the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three scenarios, three places they fail
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Change payer.&lt;/strong&gt; ResearchCo asks SearchCo to bill &lt;code&gt;mallory-org&lt;/code&gt;. SearchCo dutifully puts &lt;code&gt;account: mallory-org&lt;/code&gt; in the resource token, and the PS refuses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"denied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Mission evaluation failed: account &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;mallory-org&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; == relay.payer &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;alice-org&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"mission_checks"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SearchCo never receives an auth token, so it can't be talked into billing anyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forbidden operation.&lt;/strong&gt; ResearchCo asks for &lt;code&gt;delete&lt;/code&gt;. SearchCo really does offer delete, but the mission only allows &lt;code&gt;search&lt;/code&gt; there, so the PS refuses with &lt;code&gt;relay.scopes[SearchCo] ∋ "delete"&lt;/code&gt; failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Untrusted issuer.&lt;/strong&gt; ResearchCo skips the PS and presents an auth token signed by a rogue key, claiming to come from Alice's PS. SearchCo fetches the PS's real keys, the signature fails, and the call is rejected. This is the one scenario the receiver catches rather than the PS.&lt;/p&gt;

&lt;p&gt;All errors use the draft's format: &lt;code&gt;application/problem+json&lt;/code&gt; with an AAuth &lt;code&gt;error&lt;/code&gt; code, and &lt;code&gt;denied&lt;/code&gt; for policy refusals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;So far every hop looks like the Assistant calling PlannerCo. Part 4 covers what changes when PlannerCo passes work on to ResearchCo, and ResearchCo to SearchCo: call chaining with &lt;code&gt;upstream_token&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>oauth</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Call chaining in AAuth: passing authority on with upstream tokens</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:12:16 +0000</pubDate>
      <link>https://dev.to/darkedges/call-chaining-in-aauth-passing-authority-on-with-upstream-tokens-2j8d</link>
      <guid>https://dev.to/darkedges/call-chaining-in-aauth-passing-authority-on-with-upstream-tokens-2j8d</guid>
      <description>&lt;p&gt;Part 3 followed one hop: the Assistant calling PlannerCo with tokens from Alice's person server. But PlannerCo doesn't do the research itself. It hands it to ResearchCo, which hands the search to SearchCo. This part covers how authority travels down that chain.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;
        aauth-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      AAuth (draft-hardt-oauth-aauth-protocol-11) version of the Relay Biscuit lab, with a side-by-side comparison
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — AAuth network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml/badge.svg" alt="check"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Relay Biscuit lab&lt;/a&gt; rebuilt on &lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="nofollow noopener noreferrer"&gt;AAuth&lt;/a&gt;. It has the same operators, scenarios, budget rules and UI. Biscuit attenuation is replaced by AAuth's signed HTTP requests, person-server tokens and call chaining. Signatures, tokens and HTTP exchanges are real. Operators, work and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://aauth.demos.darkedges.com" rel="nofollow noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/aauth-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Faauth-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay AAuth playground showing the agent network, credit ledger, the approved mission with per-hop auth tokens, and the identity and receiver checks for SearchCo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;&lt;/strong&gt; for a side-by-side analysis of the two solutions.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8796" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8796&lt;/a&gt;&lt;/strong&gt;. The Biscuit lab uses 8795, so both labs can run side by side. On Windows, run &lt;code&gt;./start.ps1&lt;/code&gt;. The interface starts a demo run automatically.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm check   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; behaviour, spec-conformance, reference-client interop and debug formatting tests&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How the AAuth version works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each run creates fresh Ed25519 keys and a fresh set of origins, such as &lt;code&gt;https://search.&amp;lt;tag&amp;gt;.relay.example&lt;/code&gt;. Requests between parties are real &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt; objects routed in-process to that…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;When ResearchCo calls SearchCo, it needs a person token and an auth token for SearchCo. But ResearchCo has never met Alice. No one connected ResearchCo to her person server, and Alice didn't approve anything with ResearchCo directly.&lt;/p&gt;

&lt;p&gt;What ResearchCo does have is the auth token PlannerCo presented to it. That token proves Alice's PS authorized PlannerCo to call ResearchCo, under Alice's mission. AAuth's answer is to show that token to the PS as evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  upstream_token
&lt;/h2&gt;

&lt;p&gt;When an intermediary needs a token for the next resource, it passes the token it received as &lt;code&gt;upstream_token&lt;/code&gt;. It signs the request with its own agent token, as always:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/person-token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Signature-Key:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;sig=jwt;jwt=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;ResearchCo's agent token&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://search.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"upstream_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;the auth token PlannerCo presented&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;mission_s256&lt;/code&gt; in this request. The upstream token already carries it.&lt;/p&gt;

&lt;p&gt;It sends the same &lt;code&gt;upstream_token&lt;/code&gt; again when it exchanges SearchCo's resource token for an auth token, alongside the resource token and the person token it presented:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"resource_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"presented_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"upstream_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the person server checks
&lt;/h2&gt;

&lt;p&gt;Section 9.4.5 of the draft lists what the PS must verify before it trusts an upstream token. In the lab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ownToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upstream_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ps&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_upstream_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upstream_token does not name this PS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_upstream_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`upstream_token aud &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not the signing intermediary`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_upstream_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This PS cannot identify the calling agent the upstream token was issued to&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;directed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_upstream_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This PS holds no record of the person at the upstream resource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;callingAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In words:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It is one of this PS's own tokens,&lt;/strong&gt; a person token or an auth token, correctly signed and not expired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It was addressed to the intermediary that is now signing.&lt;/strong&gt; PlannerCo's token to ResearchCo has &lt;code&gt;aud&lt;/code&gt; set to ResearchCo. If SearchCo got hold of it and tried to use it, the &lt;code&gt;aud&lt;/code&gt; wouldn't match SearchCo's signing identity and the PS would refuse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The PS knows which agent it issued that token to.&lt;/strong&gt; That's how it identifies the calling agent one step up, even though the token itself doesn't name it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The PS knows which person the token's &lt;code&gt;sub&lt;/code&gt; refers to&lt;/strong&gt; at the upstream resource.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then it evaluates the new request against the same mission, exactly as in part 3.&lt;/p&gt;

&lt;p&gt;The lab has conformance tests for the two interesting failures: an upstream token addressed to someone else, and a correctly signed upstream token the PS has no record of issuing. Both come back &lt;code&gt;400 invalid_upstream_token&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lifetimes only shrink
&lt;/h2&gt;

&lt;p&gt;A token issued with an upstream token can't outlive it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;presented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;missionExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the whole chain expires no later than the first token, and no later than the mission. If Alice's mission ends, every token in the chain ends with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new identifier at every hop
&lt;/h2&gt;

&lt;p&gt;The person token PlannerCo got for ResearchCo has a &lt;code&gt;sub&lt;/code&gt; that means Alice at ResearchCo. The one ResearchCo gets for SearchCo has a different &lt;code&gt;sub&lt;/code&gt;, meaning Alice at SearchCo. The draft is strict about this: an issuer must not copy a directed &lt;code&gt;sub&lt;/code&gt; from an upstream token into a token it issues.&lt;/p&gt;

&lt;p&gt;The effect is that no two companies in the chain see the same identifier for Alice, so they can't use it to compare notes about her.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an intermediary can't do
&lt;/h2&gt;

&lt;p&gt;If you've used Biscuit, the obvious question is: can ResearchCo restrict what SearchCo may do?&lt;/p&gt;

&lt;p&gt;No. The draft says downstream authorization "is not required to be a subset of the upstream scopes". A flight-booking service that calls a payment processor needs payment scopes it never had itself. So scopes don't narrow down the chain, and ResearchCo has no way to add restrictions of its own.&lt;/p&gt;

&lt;p&gt;Every decision about what SearchCo may do is made by two parties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The person server,&lt;/strong&gt; against Alice's mission. In the lab, the mission's &lt;code&gt;relay.scopes&lt;/code&gt; says SearchCo may be asked for &lt;code&gt;search&lt;/code&gt; and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SearchCo itself,&lt;/strong&gt; with its local policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Section C.1.11 explains why there isn't even a record of the chain in the token: the immediate caller already identifies itself by signing, the parties further up are none of the downstream's business, and "the resource enforces, the person server attributes".&lt;/p&gt;

&lt;h2&gt;
  
  
  Caller trust, revisited
&lt;/h2&gt;

&lt;p&gt;Part 2 showed that SearchCo can refuse ResearchCo on the identity request, and that nothing forces ResearchCo to make that request. Call chaining makes the gap concrete. Here is the lab's test, condensed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ResearchCo goes straight to a person token at SearchCo, which does not trust it.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* normal identity call */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/person-token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upstream_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;person_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// …exchange the resource token, present the auth token…&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run removes the ResearchCo to SearchCo trust edge, and the search still settles. SearchCo never saw anything naming ResearchCo.&lt;/p&gt;

&lt;p&gt;The PS, however, knows exactly who asked. Every token request it handles is signed by an agent token, and its upstream check identifies the agent one step up. So "SearchCo only accepts work passed on by ResearchCo" is enforceable, but as a rule at the person server, not at SearchCo. That's the AAuth way of thinking: the resource enforces the request in front of it, and the person server owns the chain.&lt;/p&gt;

&lt;p&gt;The lab keeps SearchCo's identity check, because it works whenever an agent behaves, and labels its limits clearly in the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The library gap
&lt;/h2&gt;

&lt;p&gt;The reference client, &lt;code&gt;@aauth/agent&lt;/code&gt;, supports &lt;code&gt;upstream_token&lt;/code&gt; on the person-token request but not on the auth-token request. &lt;code&gt;exchangeToken&lt;/code&gt; has no option for it, and the high-level &lt;code&gt;createAAuthFetch&lt;/code&gt; has no upstream option at all.&lt;/p&gt;

&lt;p&gt;The lab's interop tests, which use the reference client as every agent, therefore call &lt;code&gt;exchangeToken&lt;/code&gt; without &lt;code&gt;upstream_token&lt;/code&gt;. The lab's PS accepts that, since the parameter is optional. But it then can't run the upstream checks at that step, or cap the auth token's lifetime at the upstream token's. I've reported it as &lt;a href="https://github.com/aauth-dev/packages-js/issues/37" rel="noopener noreferrer"&gt;aauth-dev/packages-js#37&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 5 covers the last company in every chain, the one that gets paid: how the clearinghouse bills Alice's organisation when no token names the company doing the work, and what changed when I ran the flow against a real person server.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>oauth</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Paying for it, and checking AAuth against a real person server</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:11:05 +0000</pubDate>
      <link>https://dev.to/darkedges/paying-for-it-and-checking-aauth-against-a-real-person-server-2079</link>
      <guid>https://dev.to/darkedges/paying-for-it-and-checking-aauth-against-a-real-person-server-2079</guid>
      <description>&lt;p&gt;The first four parts covered authority: who is calling, on whose behalf, and who decides. This last part covers money, and then checks the whole thing against the real world.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;
        aauth-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      AAuth (draft-hardt-oauth-aauth-protocol-11) version of the Relay Biscuit lab, with a side-by-side comparison
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — AAuth network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml/badge.svg" alt="check"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Relay Biscuit lab&lt;/a&gt; rebuilt on &lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="nofollow noopener noreferrer"&gt;AAuth&lt;/a&gt;. It has the same operators, scenarios, budget rules and UI. Biscuit attenuation is replaced by AAuth's signed HTTP requests, person-server tokens and call chaining. Signatures, tokens and HTTP exchanges are real. Operators, work and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://aauth.demos.darkedges.com" rel="nofollow noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/aauth-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Faauth-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay AAuth playground showing the agent network, credit ledger, the approved mission with per-hop auth tokens, and the identity and receiver checks for SearchCo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;&lt;/strong&gt; for a side-by-side analysis of the two solutions.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8796" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8796&lt;/a&gt;&lt;/strong&gt;. The Biscuit lab uses 8795, so both labs can run side by side. On Windows, run &lt;code&gt;./start.ps1&lt;/code&gt;. The interface starts a demo run automatically.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm check   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; behaviour, spec-conformance, reference-client interop and debug formatting tests&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How the AAuth version works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each run creates fresh Ed25519 keys and a fresh set of origins, such as &lt;code&gt;https://search.&amp;lt;tag&amp;gt;.relay.example&lt;/code&gt;. Requests between parties are real &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt; objects routed in-process to that…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The clearinghouse is just another resource
&lt;/h2&gt;

&lt;p&gt;Every billable call goes to a shared clearinghouse: reserve credits before the work, settle after it, release them if the work fails. In the lab the clearinghouse is an ordinary AAuth resource. Each operator reaches it with the same five-step hop as any other call, passing its upstream token, so the PS authorizes every billing call against Alice's mission too. The scope is &lt;code&gt;billing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before the first call, the clearinghouse funds the mission. It fetches the mission from the PS and checks the bytes against the hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/missions/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s256&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;s256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mission bytes do not match mission_s256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;relay&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;relay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;relay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On each call it takes the payer from the auth token's &lt;code&gt;account&lt;/code&gt; claim, which the PS signed after checking it against the mission. Nothing in the request body can change who pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who gets paid?
&lt;/h2&gt;

&lt;p&gt;Here AAuth's privacy design gets in the way. The clearinghouse has to credit the company that did the work, but as part 2 explained, no token in the three-party flow names the calling agent.&lt;/p&gt;

&lt;p&gt;The lab fills the gap with a small, clearly labelled extension: a &lt;strong&gt;provider assertion&lt;/strong&gt;. The operator signs a short JWT with its agent-provider key, bound to the key that signs the request and to this one operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assertion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;jkt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;calculateJwkThumbprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signingPublic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;iat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;relay-billing+jwt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clearinghouse verifies it against the operator's published keys and checks the binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jkt&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;thumbprint&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operation_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;operationId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Provider assertion is not bound to this request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;jkt&lt;/code&gt; must match the key that signed the HTTP request, an assertion can't be lifted from one request and replayed on another. This isn't part of AAuth. It's what a real billing service would need to add, and it's the clearest example in the lab of AAuth leaving agent identity to the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve, execute, settle
&lt;/h2&gt;

&lt;p&gt;The accounting is the same as in the Biscuit version of the lab, because none of it depends on the authorization protocol:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reservations are serialised.&lt;/strong&gt; JavaScript is single-threaded, but &lt;code&gt;await&lt;/code&gt; points interleave requests. A promise queue makes each reservation's read-then-write atomic, so two 60-credit calls against 100 credits can't both succeed. The loser gets &lt;code&gt;402 insufficient_credits&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries are idempotent.&lt;/strong&gt; A reservation is keyed by operation ID and fingerprinted. Repeating the same operation returns the existing result with &lt;code&gt;duplicate: true&lt;/code&gt;. Reusing the ID with different details is a &lt;code&gt;409&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures release.&lt;/strong&gt; If the work fails after reserving, the operator releases the reservation and the credits return to available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settlement needs a receipt.&lt;/strong&gt; The operator signs a receipt bound to the reservation grant, and the clearinghouse only settles against it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The retry scenario shows one AAuth-specific benefit: the second attempt reuses the cached auth token, so it needs no PS round trip at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it against the real thing
&lt;/h2&gt;

&lt;p&gt;A simulation can be wrong in ways its own tests won't catch. Everything so far runs against a person server I wrote. So I set up a real agent identity (part 2), and ran the AAuth walkthrough against Hellō's person server, &lt;code&gt;person.hello.coop&lt;/code&gt;, and the public test resource &lt;code&gt;whoami.aauth.dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first call, signed with just the agent token, came back &lt;code&gt;200&lt;/code&gt; with my agent's identifier. The second asked for my profile and walked the full three-party flow, pausing for me to approve in a browser. It ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://person.hello.coop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sub_…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live run &lt;strong&gt;confirmed&lt;/strong&gt; the lab's model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The challenge order is the same: agent token, then &lt;code&gt;401 requirement=person-token&lt;/code&gt;, then person token, then &lt;code&gt;401 requirement=auth-token&lt;/code&gt; with a resource token, then the auth token.&lt;/li&gt;
&lt;li&gt;Token lifetimes are capped the same way. The person token and auth token both ended exactly when the agent token did.&lt;/li&gt;
&lt;li&gt;The final response named me, but not my agent. The person token did carry my agent's identifier, in a Hellō-specific claim. The PS knows the agent; the resource doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also &lt;strong&gt;differed&lt;/strong&gt; in useful ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hellō asked for consent at the person-token request, not at a mission. It doesn't publish a mission endpoint at all.&lt;/li&gt;
&lt;li&gt;The later auth-token request came back &lt;code&gt;202 requirement=approval&lt;/code&gt;: the PS was deciding without needing me in a browser. The lab's PS decides those at once.&lt;/li&gt;
&lt;li&gt;The profile came back as claims in the auth token, with a &lt;code&gt;whoami&lt;/code&gt; scope, rather than as &lt;code&gt;openid profile&lt;/code&gt; scopes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lab now copies the consent &lt;code&gt;202&lt;/code&gt; and polling, and &lt;code&gt;pnpm live&lt;/code&gt; runs the assistant's first hop both ways, in the lab and against the real services, and prints them side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         lab   live
  signed requests           7      5
  to the person server      4      2
  consent polls             2      0
  401 challenges            2      2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is the mission. The live hop asks for a person token directly; the lab's hop gets one from the mission approval.&lt;/p&gt;

&lt;p&gt;The live run also turned up two gaps in the reference libraries, both reported: &lt;code&gt;exchangeToken&lt;/code&gt; can't send &lt;code&gt;upstream_token&lt;/code&gt; (&lt;a href="https://github.com/aauth-dev/packages-js/issues/37" rel="noopener noreferrer"&gt;#37&lt;/a&gt;), and the &lt;code&gt;--explain&lt;/code&gt; narration in &lt;code&gt;@aauth/fetch&lt;/code&gt; sometimes describes a different message from the one on the wire (&lt;a href="https://github.com/aauth-dev/packages-js/issues/38" rel="noopener noreferrer"&gt;#38&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  What a production version would need
&lt;/h2&gt;

&lt;p&gt;The lab deliberately stops short of being a real system. To make it one you would need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A real person server&lt;/strong&gt;, with persistent missions, real consent screens, revocation and a mission log the person can read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent agent keys&lt;/strong&gt;, published at stable agent-provider URLs and rotated, instead of fresh keys on every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network transport&lt;/strong&gt; between companies, where every PS call and key-discovery fetch is a round trip. The happy path makes 16 PS calls and 23 discovery fetches; caching helps the second number, not the first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A decision about caller trust.&lt;/strong&gt; If a company must only accept work from particular callers, enforce that at the person server, which sees every hop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable accounting&lt;/strong&gt;, with transactional reservations, reservation expiry and reconciliation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Across the five parts, we covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Agents that sign every request&lt;/strong&gt;, with keys bound to self-published identities, and no bearer tokens anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mission&lt;/strong&gt; that the person approves once, with deferred consent, and whose hash every later token carries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three tokens per hop:&lt;/strong&gt; a person token that says who for, a resource token that says what's needed, and an auth token the person server mints after checking both against the mission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call chaining&lt;/strong&gt;, where intermediaries present upstream tokens and the person server, not the token, holds the chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A clearinghouse&lt;/strong&gt; that bills the right payer and the right company, with the gaps AAuth leaves to the application filled in and labelled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The most important lesson for me was where the decisions end up. Almost everything that matters, who pays, what's allowed, and even who may call whom, is decided by the person server. That gives you one accountable place, at the cost of putting it on every hop.&lt;/p&gt;

&lt;p&gt;If you want the side-by-side with Biscuit, which makes the opposite trade, it's in &lt;a href="https://github.com/darkedges/aauth-lab/blob/main/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Try to break it. Remove trust, swap payers, squeeze the budget, have Alice decline, and watch the Debug tab. If you find a way to get SearchCo to do work it shouldn't, please open an issue.&lt;/p&gt;

&lt;p&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;github.com/darkedges/aauth-lab&lt;/a&gt;&lt;/strong&gt; · Demo: &lt;strong&gt;&lt;a href="https://aauth.demos.darkedges.com" rel="noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>javascript</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Signed requests and agent identity in AAuth</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:10:23 +0000</pubDate>
      <link>https://dev.to/darkedges/signed-requests-and-agent-identity-in-aauth-58o</link>
      <guid>https://dev.to/darkedges/signed-requests-and-agent-identity-in-aauth-58o</guid>
      <description>&lt;p&gt;Part 1 introduced the network and the five steps of a hop. This part covers the first step and the thing every later step depends on: how an agent proves who it is.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;
        aauth-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      AAuth (draft-hardt-oauth-aauth-protocol-11) version of the Relay Biscuit lab, with a side-by-side comparison
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — AAuth network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml/badge.svg" alt="check"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Relay Biscuit lab&lt;/a&gt; rebuilt on &lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="nofollow noopener noreferrer"&gt;AAuth&lt;/a&gt;. It has the same operators, scenarios, budget rules and UI. Biscuit attenuation is replaced by AAuth's signed HTTP requests, person-server tokens and call chaining. Signatures, tokens and HTTP exchanges are real. Operators, work and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://aauth.demos.darkedges.com" rel="nofollow noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/aauth-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Faauth-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay AAuth playground showing the agent network, credit ledger, the approved mission with per-hop auth tokens, and the identity and receiver checks for SearchCo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;&lt;/strong&gt; for a side-by-side analysis of the two solutions.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8796" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8796&lt;/a&gt;&lt;/strong&gt;. The Biscuit lab uses 8795, so both labs can run side by side. On Windows, run &lt;code&gt;./start.ps1&lt;/code&gt;. The interface starts a demo run automatically.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm check   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; behaviour, spec-conformance, reference-client interop and debug formatting tests&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How the AAuth version works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each run creates fresh Ed25519 keys and a fresh set of origins, such as &lt;code&gt;https://search.&amp;lt;tag&amp;gt;.relay.example&lt;/code&gt;. Requests between parties are real &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt; objects routed in-process to that…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  No bearer tokens
&lt;/h2&gt;

&lt;p&gt;In OAuth as most people use it, a request carries &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;, and whoever holds the token can use it. AAuth never sends a credential that works on its own. Every request is signed with the agent's private key, and every token the agent presents is bound to the matching public key.&lt;/p&gt;

&lt;p&gt;That gives two properties at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A copied token is useless.&lt;/strong&gt; It only works on requests signed by the key named inside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The receiver knows which key made the request,&lt;/strong&gt; so it can tie the request to an identity without a separate login.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The three headers
&lt;/h2&gt;

&lt;p&gt;Here is ResearchCo's first request to SearchCo, as recorded by the lab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/tasks/search&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;search.49ba54c6.relay.example&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;Content-Digest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha-256=:…:&lt;/span&gt;
&lt;span class="na"&gt;Signature-Input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sig=("@method" "@authority" "@path" "content-type" "signature-key" "content-digest");created=1792879515&lt;/span&gt;
&lt;span class="na"&gt;Signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sig=:LwNGjQhr2M7YekfJkCNS0OLxz4GZAe2xBVb…:&lt;/span&gt;
&lt;span class="na"&gt;Signature-Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sig=jwt;jwt="eyJhbGciOiJFZDI1NTE5IiwidHl…"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Signature-Input&lt;/code&gt;&lt;/strong&gt; lists what the signature covers: the method, host and path, the content type, the body digest, and the &lt;code&gt;Signature-Key&lt;/code&gt; header itself. Covering &lt;code&gt;Signature-Key&lt;/code&gt; stops someone swapping in a different token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Signature&lt;/code&gt;&lt;/strong&gt; is the Ed25519 signature over those components (RFC 9421).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Signature-Key&lt;/code&gt;&lt;/strong&gt; says where to find the key that verifies it. Here &lt;code&gt;sig=jwt&lt;/code&gt; means the key is inside a JWT: the agent token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;POST requests to the person server always include &lt;code&gt;content-digest&lt;/code&gt;, so the body can't be changed in transit. A signed &lt;code&gt;GET&lt;/code&gt; with no body covers only the method, authority, path and &lt;code&gt;signature-key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent token
&lt;/h2&gt;

&lt;p&gt;Decoded, ResearchCo's agent token looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ed25519"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aa-agent+jwt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"research-1"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://research.49ba54c6.relay.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dwk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aauth-agent.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aauth:research@research.49ba54c6.relay.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://ps.49ba54c6.relay.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cnf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jwk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OKP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ed25519"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agt-99716a01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1792879514&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1792883114&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;iss&lt;/code&gt; is the agent provider, and &lt;code&gt;dwk&lt;/code&gt; names its well-known document, so a verifier knows to fetch &lt;code&gt;{iss}/.well-known/aauth-agent.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sub&lt;/code&gt; is the agent's identifier, in the form &lt;code&gt;aauth:local@domain&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cnf.jwk&lt;/code&gt; is the public key the request must be signed with.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ps&lt;/code&gt; names the person server this agent works with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the lab each company is its own agent provider. It generates a provider key and a separate signing key, and signs its own agent token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dwk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DWK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cnf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signingPublic&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;iat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;TOKEN_TYP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Signing a request is one call to the reference library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signedFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createSignedFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signingPrivate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;signatureKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jwt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onSigned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signedFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same function signs every request in the run. Only the token in &lt;code&gt;Signature-Key&lt;/code&gt; changes from step to step: the agent token, then a person token, then an auth token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying on the receiving side
&lt;/h2&gt;

&lt;p&gt;SearchCo checks two things, in order. First the HTTP signature, then the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;authority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signature-error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`error=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;httpSignatureThumbprint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumbprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;person&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verifyToken&lt;/code&gt; fetches the issuer's well-known document, follows its &lt;code&gt;jwks_uri&lt;/code&gt;, picks the key by &lt;code&gt;kid&lt;/code&gt;, checks the JWT signature and expiry, and checks that the token's &lt;code&gt;cnf&lt;/code&gt; key is the key that signed the request (by comparing thumbprints). Those fetches are the 23 "key discovery" entries in the lab's Debug tab. They are public and unsigned, and a real deployment caches them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity-based access
&lt;/h2&gt;

&lt;p&gt;With just the agent token, SearchCo knows who is calling. That is enough for some decisions, and the draft calls this identity-based access. In the lab, SearchCo uses it for caller trust:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;demo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partyBySub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trusts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; does not accept calls from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;person_token_required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Present a person token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aauth-requirement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildAAuthHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;person-token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the ResearchCo to SearchCo trust edge in the demo and SearchCo answers &lt;code&gt;403 denied&lt;/code&gt; on this first request. No person server is contacted and no token is minted.&lt;/p&gt;

&lt;p&gt;If SearchCo does trust the caller, it answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="ne"&gt;Unauthorized&lt;/span&gt;
&lt;span class="na"&gt;AAuth-Requirement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;requirement=person-token&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/problem+json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"person_token_required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Present a person token"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller now has to show who it is acting for.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real agent identity
&lt;/h2&gt;

&lt;p&gt;The lab's agents only exist inside one run. To check the lab against the real world, I also set up a real agent with the &lt;code&gt;@aauth/bootstrap&lt;/code&gt; tool. It generated an Ed25519 key in the Windows keychain and printed the public key. I published that key and the agent metadata as two static files on Cloudflare Pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://agent.demos.darkedges.com/.well-known/aauth-agent.json
https://agent.demos.darkedges.com/.well-known/jwks.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all an agent provider needs. A signed request to the public test resource &lt;code&gt;whoami.aauth.dev&lt;/code&gt; then came back with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://agent.demos.darkedges.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aauth:local@agent.demos.darkedges.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://person.hello.coop"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resource fetched my published key, checked the signature, and knew which agent was calling. There was no account to register and no client secret to exchange.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: identity stops at the first request
&lt;/h2&gt;

&lt;p&gt;Here is the part that surprised me. In AAuth's three-party flow, the agent identifier appears only on this first request. The person token, resource token and auth token that follow don't name the agent at all.&lt;/p&gt;

&lt;p&gt;That is deliberate. Appendix C.1.6 of the draft explains that a person's relationship with a resource should survive a change of agent. If Alice switches assistants, SearchCo should still recognise her, not the assistant.&lt;/p&gt;

&lt;p&gt;The consequence is that caller trust can only be enforced on the identity request, and nothing forces an agent to make it. The lab has a test where ResearchCo skips the identity call, goes straight to the person server for a person token, and is served by SearchCo even after SearchCo has stopped trusting it.&lt;/p&gt;

&lt;p&gt;So "SearchCo only accepts work from ResearchCo" isn't a rule SearchCo can enforce on its own in AAuth. If you need it, it belongs at the person server, which sees every hop and knows which agent asked for each token. I'll come back to this in part 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 3 follows the request past the &lt;code&gt;401&lt;/code&gt;: how Alice approves a mission, and how the person, resource and auth tokens let her person server decide each hop.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>javascript</category>
      <category>http</category>
    </item>
    <item>
      <title>Delegating work across companies with AAuth</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:15:14 +0000</pubDate>
      <link>https://dev.to/darkedges/delegating-work-across-companies-with-aauth-3b9c</link>
      <guid>https://dev.to/darkedges/delegating-work-across-companies-with-aauth-3b9c</guid>
      <description>&lt;p&gt;Alice wants a piece of work done, and her organisation pays for it. She gives the job to her assistant, an AI agent. The assistant hands it to a coordinator, and the coordinator splits it between other companies. Some of those companies pass part of the job on again.&lt;/p&gt;

&lt;p&gt;Every company in that chain has to answer the same questions before it does any work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who is calling me, and do I accept work from them?&lt;/li&gt;
&lt;li&gt;On whose behalf is this request, and did that person actually approve it?&lt;/li&gt;
&lt;li&gt;Who pays, and is that the payer the person agreed to?&lt;/li&gt;
&lt;li&gt;Is this action within what the person approved?&lt;/li&gt;
&lt;li&gt;Is there still money left?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A bearer token or API key answers almost none of these. It says "the holder may do X", and anyone who copies it becomes the holder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="noopener noreferrer"&gt;AAuth&lt;/a&gt;&lt;/strong&gt; is an HTTP authorization protocol designed for agents. It is an IETF draft (this series uses draft 11) by Dick Hardt. I built &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;AAuth Network Lab&lt;/a&gt;&lt;/strong&gt; to see how far it gets on this problem, with real signatures and tokens and simulated companies. This series walks through how it works.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;
        aauth-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      AAuth (draft-hardt-oauth-aauth-protocol-11) version of the Relay Biscuit lab, with a side-by-side comparison
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — AAuth network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml/badge.svg" alt="check"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Relay Biscuit lab&lt;/a&gt; rebuilt on &lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="nofollow noopener noreferrer"&gt;AAuth&lt;/a&gt;. It has the same operators, scenarios, budget rules and UI. Biscuit attenuation is replaced by AAuth's signed HTTP requests, person-server tokens and call chaining. Signatures, tokens and HTTP exchanges are real. Operators, work and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://aauth.demos.darkedges.com" rel="nofollow noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/aauth-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Faauth-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay AAuth playground showing the agent network, credit ledger, the approved mission with per-hop auth tokens, and the identity and receiver checks for SearchCo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;&lt;/strong&gt; for a side-by-side analysis of the two solutions.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8796" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8796&lt;/a&gt;&lt;/strong&gt;. The Biscuit lab uses 8795, so both labs can run side by side. On Windows, run &lt;code&gt;./start.ps1&lt;/code&gt;. The interface starts a demo run automatically.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm check   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; behaviour, spec-conformance, reference-client interop and debug formatting tests&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How the AAuth version works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each run creates fresh Ed25519 keys and a fresh set of origins, such as &lt;code&gt;https://search.&amp;lt;tag&amp;gt;.relay.example&lt;/code&gt;. Requests between parties are real &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt; objects routed in-process to that…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://aauth.demos.darkedges.com" rel="noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you read my earlier Biscuit Trust Lab series (&lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;code&lt;/a&gt;), this is the same network, the same seven scenarios and the same clearinghouse, rebuilt on a different protocol. You don't need to have read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Alice (person) / alice-org (payer)
  └─ Assistant (Alice's agent)
       └─ PlannerCo (coordinator)
            ├─ ResearchCo [5 credits]
            │    └─ SearchCo [10 credits]
            └─ ComputeCo [25 credits]

All billable calls → shared clearinghouse
Alice's person server issues every token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice approves a budget of 100 credits. The happy path costs 40: research 5, search 10, compute 25.&lt;/p&gt;

&lt;p&gt;Seven scenarios try to break it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authorized delegation&lt;/td&gt;
&lt;td&gt;Nothing. Three operations settle for 40 credits.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change payer&lt;/td&gt;
&lt;td&gt;ResearchCo asks SearchCo to bill &lt;code&gt;mallory-org&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forbidden operation&lt;/td&gt;
&lt;td&gt;ResearchCo asks SearchCo to &lt;code&gt;delete&lt;/code&gt;, which SearchCo offers but Alice never approved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Untrusted issuer&lt;/td&gt;
&lt;td&gt;ResearchCo presents a token signed by a rogue key claiming to be Alice's person server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Competing reservations&lt;/td&gt;
&lt;td&gt;Two 60-credit calls race for a 100-credit budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry&lt;/td&gt;
&lt;td&gt;ResearchCo repeats a search with the same operation ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operation failure&lt;/td&gt;
&lt;td&gt;SearchCo reserves credits, then fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A trust editor lets you remove a "this company accepts calls from that company" relationship, or stop a company accepting Alice as a requester, and run again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four roles
&lt;/h2&gt;

&lt;p&gt;AAuth gives every participant one of a few roles. In the lab:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; make requests. The Assistant, PlannerCo, ResearchCo and ComputeCo are all agents, because each calls someone else. An agent has a signing key and an &lt;strong&gt;agent token&lt;/strong&gt; that binds that key to an identifier such as &lt;code&gt;aauth:research@research.example&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent providers&lt;/strong&gt; vouch for agents. An agent provider publishes &lt;code&gt;/.well-known/aauth-agent.json&lt;/code&gt; and a JWKS, and signs agent tokens. In the lab each company is its own agent provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resources&lt;/strong&gt; receive requests. PlannerCo, ResearchCo, SearchCo, ComputeCo and the clearinghouse are resources. Intermediaries are both: a resource to their caller, an agent to the next company.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The person server (PS)&lt;/strong&gt; represents Alice. It knows who she is, records what she approved, and issues the tokens that let agents act for her.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important design choice is the last one. Authority doesn't live in a token that gets passed down the chain. It lives at the person server, and every hop asks it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  One hop, five steps
&lt;/h2&gt;

&lt;p&gt;Every call between two companies follows the same pattern. Here is ResearchCo calling SearchCo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity call.&lt;/strong&gt; ResearchCo signs the HTTP request with its own key (RFC 9421 HTTP Message Signatures) and puts its agent token in a &lt;code&gt;Signature-Key&lt;/code&gt; header. SearchCo now knows who is calling. It answers &lt;code&gt;401&lt;/code&gt; with &lt;code&gt;AAuth-Requirement: requirement=person-token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Person token.&lt;/strong&gt; ResearchCo asks Alice's PS for a person token addressed to SearchCo. Because ResearchCo is an intermediary, it includes the token it was given by PlannerCo as &lt;code&gt;upstream_token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource token.&lt;/strong&gt; ResearchCo repeats the call, presenting the person token. SearchCo answers &lt;code&gt;401&lt;/code&gt; again with &lt;code&gt;requirement=auth-token&lt;/code&gt; and a &lt;strong&gt;resource token&lt;/strong&gt; saying what it needs: scope &lt;code&gt;search email&lt;/code&gt;, billing account &lt;code&gt;alice-org&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth token.&lt;/strong&gt; ResearchCo takes the resource token to the PS. The PS checks it against what Alice approved, and either mints an &lt;strong&gt;auth token&lt;/strong&gt; or refuses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorized call.&lt;/strong&gt; ResearchCo calls SearchCo a third time, presenting the auth token. SearchCo verifies it, applies its own policy, and does the work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every request in that list is signed by ResearchCo's key, and every token is bound to that key. A token copied off the wire is useless without the private key.&lt;/p&gt;

&lt;p&gt;Before any of this, Alice approves a &lt;strong&gt;mission&lt;/strong&gt; at her PS: what the assistant is trying to do, which tools it will use, and which services it will call. Every token in the run carries the mission's hash.&lt;/p&gt;

&lt;p&gt;On the happy path that adds up to 40 signed HTTP requests, 16 of them to the person server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the lab adds on top of AAuth
&lt;/h2&gt;

&lt;p&gt;AAuth defines the signatures, tokens and person server. It deliberately doesn't define payers, budgets or billing. The lab adds three things and labels each one as a lab extension in the UI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;relay&lt;/code&gt; member in the mission&lt;/strong&gt; that records the payer, the budget and the scopes the PS may issue for each company. The draft allows extra members in the mission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A clearinghouse&lt;/strong&gt; that reserves, settles and releases credits, the same as in the Biscuit lab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A signed provider assertion&lt;/strong&gt; on billing calls, because AAuth tokens don't name the calling agent and the clearinghouse needs to know who to pay. Part 5 explains why.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Open the demo and leave the defaults. Run the network.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Debug&lt;/strong&gt; tab lists every signed request in order, with the real &lt;code&gt;Signature&lt;/code&gt;, &lt;code&gt;Signature-Input&lt;/code&gt; and &lt;code&gt;Signature-Key&lt;/code&gt; headers, the decoded token, the &lt;code&gt;AAuth-Requirement&lt;/code&gt; challenge and a short narration of what happened. The &lt;strong&gt;Decision logic&lt;/strong&gt; tab shows, for each company, which checks passed and where each fact came from: the HTTP signature, a token the PS signed, the company's own policy, or the mission.&lt;/p&gt;

&lt;p&gt;Then try the guided exercises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protect the original payer.&lt;/strong&gt; The PS refuses to mint the auth token. SearchCo never sees a request it could act on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove a trust relationship.&lt;/strong&gt; SearchCo refuses ResearchCo on the very first request, before anyone contacts the PS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The person declines the mission.&lt;/strong&gt; Alice says no at her PS, and nothing runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run it locally
&lt;/h2&gt;

&lt;p&gt;You need Node.js 22 and pnpm 11.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/darkedges/aauth-lab
&lt;span class="nb"&gt;cd &lt;/span&gt;aauth-lab
pnpm &lt;span class="nb"&gt;install
&lt;/span&gt;pnpm dev     &lt;span class="c"&gt;# http://127.0.0.1:8796&lt;/span&gt;
pnpm check   &lt;span class="c"&gt;# 33 tests: behaviour, spec conformance, reference-client interop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All parties run inside one Cloudflare Pages function. Each run creates fresh keys and fresh origins such as &lt;code&gt;https://search.&amp;lt;tag&amp;gt;.relay.example&lt;/code&gt;, and requests between them are real &lt;code&gt;Request&lt;/code&gt; and &lt;code&gt;Response&lt;/code&gt; objects routed in process.&lt;/p&gt;

&lt;p&gt;Signing and verification use the official libraries: &lt;code&gt;@aauth/agent&lt;/code&gt; signs, &lt;code&gt;@hellocoop/httpsig&lt;/code&gt; verifies, &lt;code&gt;@aauth/resource&lt;/code&gt; verifies tokens and builds the challenges, and &lt;code&gt;jose&lt;/code&gt; signs the tokens the PS mints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming up in this series
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This post:&lt;/strong&gt; the problem, the network and the shape of a hop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed requests and agent identity:&lt;/strong&gt; RFC 9421 signatures, the &lt;code&gt;Signature-Key&lt;/code&gt; header, agent tokens and key discovery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missions, consent and three tokens:&lt;/strong&gt; how Alice approves the work, and how the PS decides each hop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call chaining:&lt;/strong&gt; how intermediaries pass authority on with &lt;code&gt;upstream_token&lt;/code&gt;, and what they can't do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paying for it, and checking against a real person server:&lt;/strong&gt; billing without agent identity, and what a live run against Hellō's person server showed.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>javascript</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Same lab, different protocol: rebuilding the Biscuit trust lab on AAuth</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sat, 24 Oct 2026 21:29:33 +0000</pubDate>
      <link>https://dev.to/darkedges/same-lab-different-protocol-rebuilding-the-biscuit-trust-lab-on-aauth-59eh</link>
      <guid>https://dev.to/darkedges/same-lab-different-protocol-rebuilding-the-biscuit-trust-lab-on-aauth-59eh</guid>
      <description>&lt;p&gt;The first four parts of this series built a delegation network on Biscuit: Alice asks PlannerCo for research, PlannerCo hands work to ResearchCo and ComputeCo, ResearchCo hands search to SearchCo, and a clearinghouse bills Alice's organisation for all of it.&lt;/p&gt;

&lt;p&gt;This part rebuilds the same lab on &lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="noopener noreferrer"&gt;AAuth&lt;/a&gt;, an HTTP authorization protocol in which agents sign every request and a person server issues the tokens. It has the same operators, the same seven scenarios, the same trust editor and the same clearinghouse. Every scenario settles the same credits as the Biscuit version: 40 / 30 / 30 / 30 / 65 / 40 / 30.&lt;/p&gt;

&lt;p&gt;What changes is &lt;strong&gt;where each decision is made&lt;/strong&gt;, and what that costs.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;
        aauth-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      AAuth (draft-hardt-oauth-aauth-protocol-11) version of the Relay Biscuit lab, with a side-by-side comparison
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — AAuth network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/darkedges/aauth-lab/actions/workflows/check.yml/badge.svg" alt="check"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Relay Biscuit lab&lt;/a&gt; rebuilt on &lt;a href="https://datatracker.ietf.org/doc/html/draft-hardt-oauth-aauth-protocol" rel="nofollow noopener noreferrer"&gt;AAuth&lt;/a&gt;. It has the same operators, scenarios, budget rules and UI. Biscuit attenuation is replaced by AAuth's signed HTTP requests, person-server tokens and call chaining. Signatures, tokens and HTTP exchanges are real. Operators, work and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://aauth.demos.darkedges.com" rel="nofollow noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/aauth-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Faauth-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay AAuth playground showing the agent network, credit ledger, the approved mission with per-hop auth tokens, and the identity and receiver checks for SearchCo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;&lt;/strong&gt; for a side-by-side analysis of the two solutions.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8796" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8796&lt;/a&gt;&lt;/strong&gt;. The Biscuit lab uses 8795, so both labs can run side by side. On Windows, run &lt;code&gt;./start.ps1&lt;/code&gt;. The interface starts a demo run automatically.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm check   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; behaviour, spec-conformance, reference-client interop and debug formatting tests&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How the AAuth version works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Each run creates fresh Ed25519 keys and a fresh set of origins, such as &lt;code&gt;https://search.&amp;lt;tag&amp;gt;.relay.example&lt;/code&gt;. Requests between parties are real &lt;code&gt;Request&lt;/code&gt;/&lt;code&gt;Response&lt;/code&gt; objects routed in-process to that…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The one-line difference
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Biscuit puts authority in the token.&lt;/strong&gt; The clearinghouse mints a root token once. Each intermediary narrows it offline by appending blocks, and each receiver decides locally with Datalog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AAuth puts authority in the person server.&lt;/strong&gt; Every hop asks Alice's person server (PS) for a fresh, key-bound auth token. The PS decides against the mission Alice approved, and receivers verify the token and apply their own policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What one hop looks like
&lt;/h2&gt;

&lt;p&gt;Before any work starts, the Assistant proposes a &lt;strong&gt;mission&lt;/strong&gt; to Alice's PS: a description, the tools it will use and the resources it will call. Only Alice can approve it, so the PS answers &lt;code&gt;202 Accepted&lt;/code&gt; with an interaction URL, a code and a pending URL. The Assistant polls with signed &lt;code&gt;GET&lt;/code&gt;s while Alice approves in her browser, then receives the mission and its &lt;code&gt;mission_s256&lt;/code&gt; hash. Every later token carries that hash.&lt;/p&gt;

&lt;p&gt;Then every hop runs the same five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identity call.&lt;/strong&gt; The caller signs the request (RFC 9421) with a self-issued agent token in the &lt;code&gt;Signature-Key&lt;/code&gt; header. The receiver now knows &lt;em&gt;who&lt;/em&gt; is calling, and answers &lt;code&gt;401&lt;/code&gt; asking for a person token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Person token.&lt;/strong&gt; The caller asks the PS for a person token for that receiver. An intermediary passes the token it was given as &lt;code&gt;upstream_token&lt;/code&gt;, which is how AAuth chains calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource token.&lt;/strong&gt; The caller presents the person token. The receiver answers &lt;code&gt;401&lt;/code&gt; again, this time with a resource token naming the scope and billing account it needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth token.&lt;/strong&gt; The caller takes the resource token to the PS. The PS checks it against the mission and either mints an auth token or refuses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorized call.&lt;/strong&gt; The receiver verifies the auth token (issuer keys, audience, expiry, and that it is bound to the key signing the request), applies local policy, and does the work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing is simulated in the protocol: the signatures, tokens and HTTP exchanges are real, routed between independent origins inside one Cloudflare Pages function. The operators, the work and the credits are simulated, as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each scenario is stopped
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Biscuit lab&lt;/th&gt;
&lt;th&gt;AAuth lab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Change the payer&lt;/td&gt;
&lt;td&gt;SearchCo's Datalog compares the payer with the call&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;The PS refuses&lt;/strong&gt; to mint the auth token: the account doesn't match the mission's payer. SearchCo never sees a token.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forbidden operation&lt;/td&gt;
&lt;td&gt;SearchCo's Datalog: no &lt;code&gt;right("delete")&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;The PS refuses&lt;/strong&gt; under its own policy, even though SearchCo offers &lt;code&gt;delete&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Untrusted issuer&lt;/td&gt;
&lt;td&gt;Signature fails against the pinned root key&lt;/td&gt;
&lt;td&gt;Signature fails against the PS's published keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove caller trust&lt;/td&gt;
&lt;td&gt;SearchCo's Datalog: &lt;code&gt;trusts_caller&lt;/code&gt; missing&lt;/td&gt;
&lt;td&gt;SearchCo returns &lt;code&gt;403&lt;/code&gt; on the first, identity-only request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget race, retry, failure&lt;/td&gt;
&lt;td&gt;Clearinghouse&lt;/td&gt;
&lt;td&gt;Clearinghouse, unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is clear: decisions that Biscuit spreads across receivers move to the PS in AAuth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AAuth gives you for free
&lt;/h2&gt;

&lt;p&gt;Biscuit tokens are bearer tokens, and Biscuit blocks don't record who appended them. To know who was calling, the Biscuit lab had to invent two things: a separate Ed25519 signature over every call, and signed delegation envelopes chained by hash.&lt;/p&gt;

&lt;p&gt;AAuth has proof of possession built in. Every request is signed, every token is bound to the key that signs the request, and an intermediary signs with its own agent token. A stolen AAuth token is useless without the key. That is AAuth's clearest advantage.&lt;/p&gt;

&lt;p&gt;AAuth also makes &lt;strong&gt;the person&lt;/strong&gt; part of the protocol. Biscuit has no notion of consent; the root token simply asserts &lt;code&gt;requester("alice")&lt;/code&gt;. In AAuth, Alice approves a mission, the PS keeps a log of every token it issues or refuses under it, and each resource sees a different pseudonymous identifier for her, so SearchCo and ComputeCo can't correlate her.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it takes away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Offline narrowing.&lt;/strong&gt; Biscuit's defining feature is that ResearchCo can restrict what SearchCo may do without asking anyone, and SearchCo can check that offline. AAuth has no equivalent: an intermediary can't constrain its sub-delegate. Only the PS and the receiver decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caller identity, on every call.&lt;/strong&gt; This one surprised me. In the three-party flow, no token AAuth issues names the agent. That's deliberate (Appendix C.1.6 of the draft): Alice's relationship with a resource should survive a change of agent. A receiver can refuse a caller on the identity-only request, but nothing forces a caller to make that request first. The lab has a test showing that ResearchCo can skip it, go straight to a person token, and be served by SearchCo even after SearchCo's trust in ResearchCo is removed.&lt;/p&gt;

&lt;p&gt;So the Biscuit lab's per-receiver "I trust this caller" rule has no binding equivalent in AAuth. That policy belongs at the PS, which sees every hop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;On one happy-path run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Biscuit lab&lt;/th&gt;
&lt;th&gt;AAuth lab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Calls to the issuer after minting&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;16 (PS), including 2 consent polls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recorded exchanges&lt;/td&gt;
&lt;td&gt;13 internal evaluations&lt;/td&gt;
&lt;td&gt;40 signed HTTP requests, 14 of them &lt;code&gt;401&lt;/code&gt; challenges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key-discovery fetches&lt;/td&gt;
&lt;td&gt;0 (root key pinned)&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mean run time (Node, in-process)&lt;/td&gt;
&lt;td&gt;~35 ms&lt;/td&gt;
&lt;td&gt;~150 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything runs in one process, so these count messages, not network latency. In a real deployment every PS call and discovery fetch is a round trip, and the PS has to be up for any work to happen. The flip side is revocation: the PS simply stops issuing, and tokens expire within the hour. Biscuit needs revocation identifiers distributed to every verifier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checked against a live person server
&lt;/h2&gt;

&lt;p&gt;A simulation can be wrong in ways its own tests won't catch, so I also ran AAuth's official walkthrough with a real agent identity, Hellō's person server (&lt;code&gt;person.hello.coop&lt;/code&gt;) and a real resource (&lt;code&gt;whoami.aauth.dev&lt;/code&gt;). The lab now has a &lt;code&gt;pnpm live&lt;/code&gt; command that runs the Assistant's first hop both ways and prints them side by side.&lt;/p&gt;

&lt;p&gt;The live run confirmed the order of challenges, the token lifetimes, and the caller-identity point: the resource that served Alice never learned which agent called, while the PS did. It also showed things the lab now copies, like the consent &lt;code&gt;202&lt;/code&gt; and polling, and things it doesn't, like a PS that defers later requests with &lt;code&gt;requirement=approval&lt;/code&gt; instead of deciding at once.&lt;/p&gt;

&lt;p&gt;It also turned up two gaps in the reference JavaScript libraries, now filed upstream: &lt;a href="https://github.com/aauth-dev/packages-js/issues/37" rel="noopener noreferrer"&gt;&lt;code&gt;exchangeToken&lt;/code&gt; can't send &lt;code&gt;upstream_token&lt;/code&gt;&lt;/a&gt;, so call chaining can't be completed through the library, and &lt;a href="https://github.com/aauth-dev/packages-js/issues/38" rel="noopener noreferrer"&gt;the &lt;code&gt;--explain&lt;/code&gt; narration doesn't always match the wire&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to pick which
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick Biscuit&lt;/strong&gt; for offline or partition-tolerant verification, for intermediaries that must narrow authority themselves, and for one uniform policy language in tokens and at receivers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick AAuth&lt;/strong&gt; for proof of possession on every request, for person consent and a central audit log, and for pseudonymous per-resource identifiers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They also combine well. AAuth can carry &lt;em&gt;who&lt;/em&gt; (a signed agent, a person, a mission) and Biscuit can carry &lt;em&gt;what&lt;/em&gt; (offline-attenuable rights), for example as an AAuth resource that accepts a Biscuit as its scoped grant. These labs keep them separate so the differences stay visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The headline is simple: Biscuit decides at the edges, AAuth decides in the middle. The more interesting result is the one I didn't expect going in: AAuth's privacy goal means receivers can't enforce which agent calls them, so "who may call me" becomes the person server's job.&lt;/p&gt;

&lt;p&gt;The full side-by-side is in &lt;a href="https://github.com/darkedges/aauth-lab/blob/main/COMPARISON.md" rel="noopener noreferrer"&gt;COMPARISON.md&lt;/a&gt;. Try both labs, run the same scenario in each, and compare the Debug tabs.&lt;/p&gt;

&lt;p&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/darkedges/aauth-lab" rel="noopener noreferrer"&gt;github.com/darkedges/aauth-lab&lt;/a&gt;&lt;/strong&gt; · Demo: &lt;strong&gt;&lt;a href="https://aauth.demos.darkedges.com" rel="noopener noreferrer"&gt;aauth.demos.darkedges.com&lt;/a&gt;&lt;/strong&gt; · Biscuit version: &lt;strong&gt;&lt;a href="https://biscuits.demos.darkedges.com/#biscuit" rel="noopener noreferrer"&gt;biscuits.demos.darkedges.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>oauth</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Step-up MFA, attenuation, and what's honestly not done</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:51:54 +0000</pubDate>
      <link>https://dev.to/darkedges/step-up-mfa-attenuation-and-whats-honestly-not-done-27h5</link>
      <guid>https://dev.to/darkedges/step-up-mfa-attenuation-and-whats-honestly-not-done-27h5</guid>
      <description>&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens" rel="noopener noreferrer"&gt;darkedges/pf12.3-biscuit-datalog-tokens&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We've covered why a hybrid works, the token generator plugin, and the Terraform plus end-to-end test. Part 3 ended with alice holding &lt;code&gt;orders:write&lt;/code&gt; and still getting a &lt;code&gt;403&lt;/code&gt;. This part unlocks that write, narrows the token in a few ways, and then says plainly what doesn't work yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why the write was refused
&lt;/h2&gt;

&lt;p&gt;orders-api's policy has three rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allow if scope("orders:read"), operation("read");
allow if scope("orders:write"), operation("write"), amr("mfa") trusting authority, ed25519/&amp;lt;attestation key&amp;gt;;
deny if true;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A write needs the scope &lt;strong&gt;and&lt;/strong&gt; an &lt;code&gt;amr("mfa")&lt;/code&gt; fact. It also has to be a fact the service &lt;em&gt;trusts&lt;/em&gt;. &lt;code&gt;trusting authority, ed25519/&amp;lt;key&amp;gt;&lt;/code&gt; means: look for &lt;code&gt;amr("mfa")&lt;/code&gt; in the authority block (PingFederate's) or in a block signed by the attestation key, and nowhere else.&lt;/p&gt;

&lt;p&gt;When no allow rule matches, Biscuit only reports that &lt;code&gt;deny if true&lt;/code&gt; matched, which tells you nothing. So orders-api works out which requirement is missing and says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write requires amr(&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;mfa&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;) signed by ed25519/59c06655… (step-up)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/orders/123"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The gotcha in &lt;code&gt;trusting&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;My first version of that rule was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allow if scope("orders:write"), operation("write"), amr("mfa") trusting ed25519/&amp;lt;attestation key&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It never matched, even with the attestation present. &lt;code&gt;trusting&lt;/code&gt; &lt;strong&gt;replaces&lt;/strong&gt; a rule's default scope; it doesn't add to it. Without &lt;code&gt;authority&lt;/code&gt; in the list, the rule could no longer see &lt;code&gt;scope("orders:write")&lt;/code&gt; in PingFederate's authority block. It failed silently, with no error. If you write Biscuit policies with third-party blocks, &lt;code&gt;trusting authority, &amp;lt;key&amp;gt;&lt;/code&gt; is almost always what you mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Step-up: an attestation as a third-party block
&lt;/h2&gt;

&lt;p&gt;Biscuit has a mechanism designed for exactly this: &lt;strong&gt;third-party blocks&lt;/strong&gt;. The holder sends a &lt;em&gt;request&lt;/em&gt; derived from its token to an attestor. The attestor signs a block (here containing &lt;code&gt;amr("mfa")&lt;/code&gt;) bound to that specific token, and the holder appends it. Nobody else can move that block onto a different token, and the attestor never sees the token itself.&lt;/p&gt;

&lt;p&gt;In the demo app, &lt;strong&gt;Add MFA attestation&lt;/strong&gt; does exactly that. The Token tab then shows a second block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="nx"&gt;third&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;party&lt;/span&gt; &lt;span class="err"&gt;·&lt;/span&gt; &lt;span class="nx"&gt;attestation&lt;/span&gt;    &lt;span class="nx"&gt;signed&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt; &lt;span class="nx"&gt;ed25519&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="nx"&gt;c06655&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;
   &lt;span class="nf"&gt;amr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mfa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;POST again and it's a &lt;code&gt;200&lt;/code&gt;. From the command line, &lt;code&gt;make mfa&lt;/code&gt; followed by &lt;code&gt;make call&lt;/code&gt; does the same.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest part: PingFederate isn't the attestor yet
&lt;/h3&gt;

&lt;p&gt;The design I wanted was &lt;strong&gt;PingFederate signs the attestation&lt;/strong&gt;, right after it has run MFA. It has the user's session and knows MFA happened, and it already holds signing keys. biscuit-java even has the API for it.&lt;/p&gt;

&lt;p&gt;It doesn't work today. biscuit-java 4.0.1, the latest on Maven Central, still uses the &lt;strong&gt;legacy&lt;/strong&gt; third-party request format, which carries a &lt;code&gt;previousKey&lt;/code&gt;. biscuit-auth 6 (Rust, which orders-api and the demo app use) sends the newer format with a &lt;code&gt;previous_signature&lt;/code&gt;, and rejects the legacy one. Hand a Rust-made request to biscuit-java and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;InvalidProtocolBufferException: Message missing required fields: previousKey
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the demo &lt;strong&gt;simulates&lt;/strong&gt; PingFederate's attestor with a separate Ed25519 key in &lt;code&gt;.keys/attestation.env&lt;/code&gt;, signing with biscuit-auth. The flow, the policy and the trust model are exactly what PingFederate would do. Only the signer is a stand-in.&lt;/p&gt;

&lt;p&gt;There are three ways to get the real thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;biscuit-java catches up&lt;/strong&gt; with the newer third-party format. Then the attestor can be a small PingFederate endpoint or plugin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sidecar attestor&lt;/strong&gt; next to PingFederate, using biscuit-auth. PingFederate decides that MFA happened; the sidecar signs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step up at the IdP and re-exchange.&lt;/strong&gt; This works today, and it's the most "hybrid" option. Run MFA in PingFederate and put the &lt;code&gt;amr&lt;/code&gt; claim in the JWT. Then make another token exchange and map &lt;code&gt;amr&lt;/code&gt; into the authority block. The generator already allows an &lt;code&gt;amr&lt;/code&gt; fact; it's in the default fact list. The cost is a round trip to PingFederate for step-up, which you'd make anyway for the MFA itself. The demo doesn't wire this up, because its HTML form login has no MFA, but nothing in the plugin stops it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Attenuation: narrowing without asking
&lt;/h2&gt;

&lt;p&gt;With the write unlocked, the demo app's &lt;strong&gt;Attenuate&lt;/strong&gt; buttons append blocks that only ever narrow the token:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Button&lt;/th&gt;
&lt;th&gt;Block appended&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if operation("read");&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;POST → &lt;code&gt;403&lt;/code&gt;, naming the failed check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only this order&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if resource($r), $r == "/orders/999";&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/orders/999&lt;/code&gt; → 200, &lt;code&gt;/orders/123&lt;/code&gt; → 403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expire in 60 s&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if time($t), $t &amp;lt;= &amp;lt;now+60s&amp;gt;;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Valid for a minute, even though PingFederate gave it five&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;your Datalog&lt;/td&gt;
&lt;td&gt;e.g. &lt;code&gt;check if operation("read");&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these call PingFederate, and none need a key. This is the Trust Lab's delegation step, now on a token that started life in an enterprise IdP. A gateway can hand a downstream service a token that's read-only, for one order, for one minute, in microseconds.&lt;/p&gt;

&lt;p&gt;Two properties are worth checking yourself in the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Values can't inject Datalog.&lt;/strong&gt; The order ID and the time go into the block as parameters, not pasted text. Even so, the app only accepts order IDs of letters, digits, &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A holder can't widen a token.&lt;/strong&gt; Append a block that says &lt;code&gt;amr("mfa"); scope("orders:admin");&lt;/code&gt; yourself and POST: still &lt;code&gt;403&lt;/code&gt;. Facts in an ordinary appended block aren't trusted by the policy. Only the authority block and the attestation key are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Seal&lt;/strong&gt; finishes the token: no further blocks can be appended, and the app disables those buttons. &lt;strong&gt;Fresh Biscuit from PingFederate&lt;/strong&gt; runs the exchange again.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Revocation and certificate binding
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens/blob/main/scripts/demo.sh" rel="noopener noreferrer"&gt;standalone demo&lt;/a&gt; (&lt;code&gt;make demo&lt;/code&gt;, 19 checks, no PingFederate needed) covers two things the web app doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Revocation.&lt;/strong&gt; Each block has a revocation ID. Revoke the &lt;strong&gt;authority block's&lt;/strong&gt; ID, which the plugin logs at mint time, and the original token &lt;em&gt;and every narrowed copy&lt;/em&gt; fail. A token with a different ID is unaffected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate binding.&lt;/strong&gt; A token minted with a &lt;code&gt;cnf_x5t_s256&lt;/code&gt; attribute only works when presented over the matching client certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is not proven
&lt;/h2&gt;

&lt;p&gt;The prototype works end to end. That's different from being production-ready:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PingFederate as the MFA attestor&lt;/strong&gt;: blocked by the biscuit-java format gap above; the demo simulates it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation distribution&lt;/strong&gt;: orders-api reads revoked IDs from a file. Getting them to every service quickly, by push, pull or short lifetimes, is a design problem I haven't solved here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introspection&lt;/strong&gt;: PingFederate can confirm a narrowed Biscuit's signature, but not whether it's &lt;em&gt;authorized&lt;/em&gt;. That depends on request facts only the service has. &lt;code&gt;active: true&lt;/code&gt; would mean "authentic and unexpired", nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy governance&lt;/strong&gt;: each service carries its own Datalog. Keeping policies consistent across services needs version control, review and tests, like any other code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ecosystem&lt;/strong&gt;: PingAccess and off-the-shelf gateways don't understand Biscuits. The hybrid sidesteps this by keeping JWTs at the edge, but it's a real limit on how far in the Biscuit can go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo shortcuts&lt;/strong&gt;: HS256 JWTs with a symmetric key, generated secrets in local Terraform state, self-signed TLS, and the root key in a PingFederate config field rather than an HSM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size&lt;/strong&gt;: every block adds bytes. The demo's token grew from 436 bytes (one block) to about 1 KB (four blocks). Seal early if headers matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Back to the Trust Lab's list
&lt;/h2&gt;

&lt;p&gt;Part 4 of the Trust Lab ended with what a production version would need. Here's where an IdP-minted root moves the needle, and where it doesn't:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trust Lab production need&lt;/th&gt;
&lt;th&gt;With PingFederate minting the root&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Persistent keys with rotation&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Better.&lt;/strong&gt; The key lives in the IdP's encrypted config; &lt;code&gt;root_key_id&lt;/code&gt; supports rotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revocation&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Partly.&lt;/strong&gt; IDs are logged at mint and revoking the authority block cascades; distribution is still open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authenticated transports&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Better.&lt;/strong&gt; Certificate binding via &lt;code&gt;cnf_x5t_s256&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payer consent&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Possible.&lt;/strong&gt; PingFederate's consent and authorization policies run before the exchange&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable accounting, reconciliation&lt;/td&gt;
&lt;td&gt;Unchanged; that's the clearinghouse's job, not the IdP's&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The thing I set out to test was whether an existing enterprise IdP could be the root of trust for Biscuits without giving up anything that makes either side useful. I think the answer is yes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PingFederate keeps login, MFA, consent and client governance.&lt;/li&gt;
&lt;li&gt;The edge keeps speaking OAuth.&lt;/li&gt;
&lt;li&gt;One token exchange turns an access token into a capability that services can narrow, delegate and check offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try to break it. Clone the &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, run &lt;code&gt;make up tf-plan tf-apply web&lt;/code&gt;, and see if you can make orders-api accept a write it shouldn't. If you work on PingFederate or Biscuit, I'd especially like feedback on the fact vocabulary, the reserved names, and the step-up options. Open an issue on GitHub.&lt;/p&gt;

&lt;p&gt;Thanks for reading the series.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>pingfederate</category>
      <category>datalog</category>
    </item>
    <item>
      <title>Configuring PingFederate token exchange with Terraform, and testing it end to end</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:51:35 +0000</pubDate>
      <link>https://dev.to/darkedges/configuring-pingfederate-token-exchange-with-terraform-and-testing-it-end-to-end-496g</link>
      <guid>https://dev.to/darkedges/configuring-pingfederate-token-exchange-with-terraform-and-testing-it-end-to-end-496g</guid>
      <description>&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens" rel="noopener noreferrer"&gt;darkedges/pf12.3-biscuit-datalog-tokens&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 2 built a token generator plugin. On its own it does nothing: PingFederate needs a login, an access token manager, a client, a processor policy, a generator instance, a generator group and a mapping between them before a single Biscuit comes out.&lt;/p&gt;

&lt;p&gt;This part configures all of that with Terraform, then runs the full flow in a browser: log in, exchange, call an API, get refused, and see why.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The stack
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens/blob/main/docker-compose.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;docker-compose.yaml&lt;/code&gt;&lt;/a&gt; runs two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pingfederate&lt;/code&gt;&lt;/strong&gt;: PingFederate 12.3.3 with the getting-started server profile, built from the multi-stage Dockerfile from Part 2, so the plugin is already deployed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform&lt;/code&gt;&lt;/strong&gt;: a &lt;code&gt;hashicorp/terraform&lt;/code&gt; container in a &lt;code&gt;tools&lt;/code&gt; profile. It reaches the admin API at &lt;code&gt;https://pingfederate:9999&lt;/code&gt; on the compose network, so you don't need Terraform on your machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything runs through &lt;code&gt;make&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make up          &lt;span class="c"&gt;# build the image (plugin compiled and tested inside Docker) and start PingFederate&lt;/span&gt;
make tf-plan     &lt;span class="c"&gt;# generate the Biscuit root key once, init, plan → terraform/tfplan&lt;/span&gt;
make tf-apply    &lt;span class="c"&gt;# apply the saved plan&lt;/span&gt;
make tf-creds    &lt;span class="c"&gt;# generated passwords for alice and bob, and the client secret&lt;/span&gt;
make web         &lt;span class="c"&gt;# orders-api + the demo web app, prints "Ready" when 8090 is listening&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. What Terraform creates
&lt;/h2&gt;

&lt;p&gt;Seventeen resources take a blank PingFederate to a working token exchange:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Demo users &lt;code&gt;alice&lt;/code&gt; and &lt;code&gt;bob&lt;/code&gt;, with generated passwords&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;random_password&lt;/code&gt; + &lt;code&gt;pingfederate_password_credential_validator&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML form login&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pingfederate_idp_adapter&lt;/code&gt; + &lt;code&gt;pingfederate_oauth_idp_adapter_mapping&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scopes &lt;code&gt;orders:read&lt;/code&gt;, &lt;code&gt;orders:write&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pingfederate_oauth_server_settings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT access tokens carrying &lt;code&gt;username&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pingfederate_oauth_access_token_manager&lt;/code&gt; + &lt;code&gt;pingfederate_oauth_access_token_mapping&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client &lt;code&gt;orders-web&lt;/code&gt;: authorization code, refresh token, token exchange&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pingfederate_oauth_client&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validates the incoming JWT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pingfederate_idp_token_processor&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Picks &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;client_id&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pingfederate_oauth_token_exchange_processor_policy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The Biscuit generator instance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;restapi_object&lt;/code&gt; → &lt;code&gt;/sp/tokenGenerators&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Routes the Biscuit token type to it&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;restapi_object&lt;/code&gt; → &lt;code&gt;/oauth/tokenExchange/generator/groups&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy attributes → generator contract&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pingfederate_oauth_token_exchange_token_generator_mapping&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Makes that group the default&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pingfederate_oauth_token_exchange_generator_settings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The token exchange pipeline from Part 1 maps directly onto the last six rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validating the subject token
&lt;/h3&gt;

&lt;p&gt;The processor is PingFederate's built-in &lt;code&gt;BearerAccessTokenTokenProcessor&lt;/code&gt;, pointed at the &lt;strong&gt;same access token manager that issued the JWT&lt;/strong&gt;. PingFederate validates its own tokens, with no JWKS plumbing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pingfederate_idp_token_processor"&lt;/span&gt; &lt;span class="s2"&gt;"access_token"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;processor_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"biscuitAccessTokenProcessor"&lt;/span&gt;
  &lt;span class="nx"&gt;plugin_descriptor_ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"org.sourceid.wstrust.processor.oauth.BearerAccessTokenTokenProcessor"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Access Token Manager"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pingfederate_oauth_access_token_manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;manager_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Scope value as single string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;# core: aud, authorization_details, client_id, expires_at, iss, scope; extended: username&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The processor's core contract has &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;scope&lt;/code&gt;, but &lt;strong&gt;not&lt;/strong&gt; the user. The user arrives as the extended attribute &lt;code&gt;username&lt;/code&gt;, which has to match the access token manager's contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  The processor policy: what PingFederate lets through
&lt;/h3&gt;

&lt;p&gt;This is the governance point from Part 1. The policy chooses which attributes go to the generator. This is where you'd add issuance criteria, such as "only this client" or "only if the token has this scope":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pingfederate_oauth_token_exchange_processor_policy"&lt;/span&gt; &lt;span class="s2"&gt;"biscuit"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;policy_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"biscuitExchange"&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Access token to Biscuit"&lt;/span&gt;
  &lt;span class="c1"&gt;# "subject" is a built-in core attribute of every processor policy.&lt;/span&gt;
  &lt;span class="nx"&gt;attribute_contract&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;extended_attributes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"client_id"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"scope"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;processor_mappings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="nx"&gt;subject_token_type&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"urn:ietf:params:oauth:token-type:access_token"&lt;/span&gt;
    &lt;span class="nx"&gt;subject_token_processor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pingfederate_idp_token_processor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processor_id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;attribute_contract_fulfillment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;subject&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SUBJECT_TOKEN"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"username"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;client_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SUBJECT_TOKEN"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"client_id"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;scope&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SUBJECT_TOKEN"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"scope"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The comment is there because I tried to declare &lt;code&gt;subject&lt;/code&gt; myself, and the provider refuses: it's read-only.)&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The provider gap: &lt;code&gt;restapi&lt;/code&gt; for two objects
&lt;/h2&gt;

&lt;p&gt;The PingFederate Terraform provider (1.10.0, the latest at the time of writing) has no resource for &lt;strong&gt;SP token generators&lt;/strong&gt; or &lt;strong&gt;token exchange generator groups&lt;/strong&gt;. Those are exactly the two objects this project needs.&lt;/p&gt;

&lt;p&gt;Rather than shell out to scripts, I used the generic &lt;a href="https://registry.terraform.io/providers/Mastercard/restapi/latest" rel="noopener noreferrer"&gt;&lt;code&gt;Mastercard/restapi&lt;/code&gt;&lt;/a&gt; provider against the admin API. Those objects still go through plan, apply and destroy like everything else. Abridged from &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens/blob/main/terraform/token_exchange.tf" rel="noopener noreferrer"&gt;&lt;code&gt;token_exchange.tf&lt;/code&gt;&lt;/a&gt;, where the literals are variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"restapi_object"&lt;/span&gt; &lt;span class="s2"&gt;"biscuit_generator"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/sp/tokenGenerators"&lt;/span&gt;
  &lt;span class="nx"&gt;object_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"biscuit"&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"biscuit"&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Biscuit token generator"&lt;/span&gt;
    &lt;span class="nx"&gt;pluginDescriptorRef&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"com.darkedges.pingfederate.biscuit.BiscuitTokenGenerator"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Root Private Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;biscuit_root_private_key&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Root Key ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Issuer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pf_runtime_url&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Token Lifetime (seconds)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"300"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Audiences"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"orders-api"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Fact Attributes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"subject,client_id,scope"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;attributeContract&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;coreAttributes&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"subject"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
      &lt;span class="nx"&gt;extendedAttributes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"client_id"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"scope"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_server_additions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_changes_to&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"configuration.fields"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"attributeContract.extendedAttributes"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those last two lines took three plans to get right. After the first apply, every plan showed both objects changed, because PingFederate &lt;em&gt;adds&lt;/em&gt; things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;location&lt;/code&gt; and &lt;code&gt;lastModified&lt;/code&gt; fields;&lt;/li&gt;
&lt;li&gt;an empty &lt;code&gt;resourceUris&lt;/code&gt; list on the group;&lt;/li&gt;
&lt;li&gt;the private key returned as &lt;code&gt;encryptedValue&lt;/code&gt; instead of &lt;code&gt;value&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;extended attributes in a different order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ignore_server_additions&lt;/code&gt; ignores fields the server adds but still detects changes to fields you set. Now a plan straight after an apply says &lt;strong&gt;No changes&lt;/strong&gt;, and editing the Terraform still triggers an update.&lt;/p&gt;

&lt;p&gt;The generator group is the piece that makes &lt;code&gt;requested_token_type&lt;/code&gt; work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"restapi_object"&lt;/span&gt; &lt;span class="s2"&gt;"biscuit_generator_group"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/oauth/tokenExchange/generator/groups"&lt;/span&gt;
  &lt;span class="nx"&gt;object_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"biscuit"&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"biscuit"&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Biscuit"&lt;/span&gt;
    &lt;span class="nx"&gt;generatorMappings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;requestedTokenType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"urn:darkedges:params:oauth:token-type:biscuit"&lt;/span&gt;
      &lt;span class="nx"&gt;tokenGenerator&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;restapi_object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;biscuit_generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object_id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;defaultMapping&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_server_additions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Smaller things worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The root key is generated by the plugin itself.&lt;/strong&gt; &lt;code&gt;make tf-plan&lt;/code&gt; runs the plugin jar's &lt;code&gt;keygen&lt;/code&gt; &lt;em&gt;inside the PingFederate container&lt;/em&gt;, so you don't need a JDK. It writes the result to a git-ignored &lt;code&gt;terraform/biscuit.auto.tfvars&lt;/code&gt;. Terraform passes the private key to PingFederate, and the services get the public key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The JWT symmetric key must be hex.&lt;/strong&gt; With the access token manager's "Encoding" field left blank, PingFederate expects the HS256 key as hex. My first apply failed with &lt;code&gt;Key with key id 'k1' is not valid hex&lt;/code&gt;, and &lt;code&gt;random_bytes.hex&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated secrets live in Terraform state.&lt;/strong&gt; Demo passwords, the client secret and the JWT key are &lt;code&gt;random_*&lt;/code&gt; resources. That's fine for a lab, but in production you'd source them from a vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destroy is clean.&lt;/strong&gt; &lt;code&gt;make tf-destroy&lt;/code&gt; removes all 17 resources in dependency order. PingFederate clears the default generator group reference itself when the group goes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Testing it end to end
&lt;/h2&gt;

&lt;p&gt;With PingFederate running and Terraform applied, &lt;code&gt;make web&lt;/code&gt; starts two things: &lt;code&gt;orders-api&lt;/code&gt; (Rust, biscuit-auth 6) on port 8091, and the demo web app on port 8090. It prints a "Ready" line only once both are listening.&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;http://localhost:8090&lt;/code&gt; and log in as &lt;code&gt;alice&lt;/code&gt;, with the password from &lt;code&gt;make tf-creds&lt;/code&gt;. The app runs a standard authorization code flow against PingFederate's HTML form, then does the token exchange. The &lt;strong&gt;Token&lt;/strong&gt; tab shows the Biscuit PingFederate minted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;issuer("https://localhost:9031");
user("alice");
client("orders-web");
scope("orders:read");
scope("orders:write");
check if time($t), $t &amp;lt;= 2026-10-04T11:23:13Z;
check if audience($a), {"orders-api"}.contains($a);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's labelled &lt;em&gt;authority · minted by PingFederate&lt;/em&gt; and marked &lt;em&gt;signature verified&lt;/em&gt; against PingFederate's public key.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;HTTP activity&lt;/strong&gt; tab lists every call in order, each with &lt;strong&gt;Request / Response&lt;/strong&gt; tabs. The request is a runnable curl with &lt;strong&gt;Copy curl&lt;/strong&gt;, and the response is the JSON with &lt;strong&gt;Copy response&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Authorization code → access token (JWT)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;token_type=Bearer&lt;/code&gt;, &lt;code&gt;expires_in=3599&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Token exchange: JWT → Biscuit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;issued_token_type=…:biscuit&lt;/code&gt;, &lt;code&gt;token_type=N_A&lt;/code&gt;, &lt;code&gt;expires_in=299&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET /orders/123&lt;/code&gt; with the Biscuit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;{"allowed": true, "user": "alice"}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /orders/123&lt;/code&gt; with the Biscuit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt;, &lt;code&gt;write requires amr("mfa") signed by ed25519/59c0… (step-up)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Call 3 shows the point of the whole design: &lt;strong&gt;orders-api didn't call PingFederate.&lt;/strong&gt; It verified the signature with the public key, added facts about the request (&lt;code&gt;operation("read")&lt;/code&gt;, &lt;code&gt;resource("/orders/123")&lt;/code&gt;, &lt;code&gt;audience("orders-api")&lt;/code&gt;, the time), and evaluated its policy locally.&lt;/p&gt;

&lt;p&gt;Call 4 is the cliffhanger. Alice has &lt;code&gt;orders:write&lt;/code&gt;, but the policy also wants proof of MFA, as a block signed by a key PingFederate controls. That's Part 4.&lt;/p&gt;

&lt;h3&gt;
  
  
  Without a browser
&lt;/h3&gt;

&lt;p&gt;The same flow runs from the command line, which is handy for CI or for seeing the raw calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make auto-login &lt;span class="nv"&gt;LOGIN_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bob   &lt;span class="c"&gt;# fills in PingFederate's HTML form with curl&lt;/span&gt;
make exchange                    &lt;span class="c"&gt;# JWT → Biscuit, prints its Datalog&lt;/span&gt;
make api-pf                      &lt;span class="c"&gt;# orders-api in another terminal&lt;/span&gt;
make call                        &lt;span class="c"&gt;# GET → 200, POST → 403&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;In Part 4 we unlock that write: a step-up MFA attestation appended as a third-party block, then attenuation and sealing in the demo app. Then the honest part: what this design doesn't solve yet, and the library gap that stops PingFederate from being the attestor today.&lt;/p&gt;

</description>
      <category>pingfederate</category>
      <category>terraform</category>
      <category>oauth</category>
      <category>rust</category>
    </item>
    <item>
      <title>Building a PingFederate token generator that mints Biscuits</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:51:23 +0000</pubDate>
      <link>https://dev.to/darkedges/building-a-pingfederate-token-generator-that-mints-biscuits-55n4</link>
      <guid>https://dev.to/darkedges/building-a-pingfederate-token-generator-that-mints-biscuits-55n4</guid>
      <description>&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens" rel="noopener noreferrer"&gt;darkedges/pf12.3-biscuit-datalog-tokens&lt;/a&gt;, plugin source in &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens/tree/main/pf-biscuit-generator" rel="noopener noreferrer"&gt;&lt;code&gt;pf-biscuit-generator/&lt;/code&gt;&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part 1 argued for a hybrid: PingFederate issues JWTs at the edge, and token exchange turns them into Biscuits for everything inside. This part builds the one piece PingFederate doesn't ship, a &lt;strong&gt;token generator&lt;/strong&gt; that mints a Biscuit.&lt;/p&gt;

&lt;p&gt;It's a few hundred lines of Java. Most of the interesting decisions aren't in the Java, though. They're about what you let into the authority block.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The SDK contract
&lt;/h2&gt;

&lt;p&gt;PingFederate's token exchange calls token generators through the same SDK interface its WS-Trust STS uses, &lt;code&gt;org.sourceid.wstrust.plugin.generate.TokenGenerator&lt;/code&gt;. A generator does three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;describes itself&lt;/strong&gt;: its name, admin UI fields, the token type it produces, and its attribute contract;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;receives configuration&lt;/strong&gt; when an admin saves the instance;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;generates a token&lt;/strong&gt; from a map of attributes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The descriptor is where the token type lives. That URN is what clients send as &lt;code&gt;requested_token_type&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;TOKEN_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"urn:darkedges:params:oauth:token-type:biscuit"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;descriptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TokenPluginDescriptor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gui&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;TOKEN_TYPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"subject"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;descriptor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSupportsExtendedContract&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core contract is just &lt;code&gt;subject&lt;/code&gt;. The extended contract lets an admin add &lt;code&gt;client_id&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;group&lt;/code&gt;, &lt;code&gt;amr&lt;/code&gt; or anything else the processor policy provides, without changing code.&lt;/p&gt;

&lt;p&gt;The admin UI exposes six fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Becomes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Root Private Key&lt;/td&gt;
&lt;td&gt;The Ed25519 key that signs the authority block (an encrypted field)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root Key ID&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;root_key_id&lt;/code&gt; in the token, for key rotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issuer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;issuer("…")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token Lifetime (seconds)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if time($t), $t &amp;lt;= &amp;lt;now + lifetime&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audiences&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if audience($a), {…}.contains($a)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fact Attributes&lt;/td&gt;
&lt;td&gt;Which contract attributes become facts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The plugin uses &lt;a href="https://github.com/biscuit-auth/biscuit-java" rel="noopener noreferrer"&gt;biscuit-java&lt;/a&gt; 4.0.1, the JVM implementation of Biscuit.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Attributes in, facts out, and never as strings
&lt;/h2&gt;

&lt;p&gt;This is the first design rule, and it's easy to get wrong. The tempting implementation is string building:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DON'T&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add_authority_fact&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user(\""&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"\")"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a subject ever contains &lt;code&gt;alice"); scope("orders:admin&lt;/code&gt;, you've just let an attribute value write Datalog. In an IdP, attribute values come from directories, upstream federation partners and user-editable profiles. They aren't safe input.&lt;/p&gt;

&lt;p&gt;So every value goes through biscuit-java's builder API as a &lt;strong&gt;string term&lt;/strong&gt;. A term is data and is never parsed as Datalog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set_context&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pingfederate:token-exchange"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add_authority_fact&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"issuer"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;))));&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Entry&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;factAttributes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entrySet&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getKey&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
    &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add_authority_fact&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;))));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checks are built the same way: predicates, variables and expressions, not strings. Here's the expiry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// check if time($t), $t &amp;lt;= &amp;lt;expiresAt&amp;gt;&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add_authority_check&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pred&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"time"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"t"&lt;/span&gt;&lt;span class="o"&gt;))),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Expression&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Binary&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Expression&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Op&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LessOrEqual&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Expression&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"t"&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Expression&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;))))));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a unit test that mints a token for the subject &lt;code&gt;mallory"); scope("orders:write&lt;/code&gt;. It checks that the result contains exactly one scope fact and that a write is still refused.&lt;/p&gt;

&lt;p&gt;A small but useful detail: OAuth's &lt;code&gt;scope&lt;/code&gt; arrives as one space-separated string. The minter splits it into one &lt;code&gt;scope("…")&lt;/code&gt; fact per scope, so Datalog policies can match individual scopes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The fact names a minter must refuse
&lt;/h2&gt;

&lt;p&gt;The second design rule is subtler, and I think it's the most important thing in this article.&lt;/p&gt;

&lt;p&gt;A Biscuit authorizer combines facts from two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the token&lt;/strong&gt;, whose authority block is trusted;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the service itself&lt;/strong&gt;, which adds &lt;em&gt;ambient&lt;/em&gt; facts describing the request it's handling: &lt;code&gt;time(...)&lt;/code&gt;, &lt;code&gt;operation("read")&lt;/code&gt;, &lt;code&gt;resource("/orders/123")&lt;/code&gt;, &lt;code&gt;audience("orders-api")&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it runs a policy like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allow if scope("orders:read"), operation("read");
allow if scope("orders:write"), operation("write"), amr("mfa") trusting authority, ed25519/&amp;lt;attestation key&amp;gt;;
deny if true;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, the authorizer trusts authority-block facts &lt;strong&gt;exactly as much as its own&lt;/strong&gt;. So if any PingFederate attribute were ever mapped to a fact called &lt;code&gt;operation&lt;/code&gt;, a token could carry &lt;code&gt;operation("write")&lt;/code&gt; and satisfy the write rule &lt;em&gt;on a read request&lt;/em&gt;. The token would be forging facts about the request.&lt;/p&gt;

&lt;p&gt;So the minter has a reserved list, and refuses to start if an admin maps an attribute onto any of these names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;RESERVED_FACTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"time"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"operation"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"resource"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"audience"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tls_client_cert_sha256"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"issuer"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"hop"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;issuer&lt;/code&gt; is on the list because the plugin sets it from configuration. &lt;code&gt;hop&lt;/code&gt; is reserved for services to tag delegation hops.&lt;/p&gt;

&lt;p&gt;The broader lesson: &lt;strong&gt;the fact vocabulary is a contract between your IdP and every service that verifies its tokens.&lt;/strong&gt; Which names the IdP asserts (&lt;code&gt;user&lt;/code&gt;, &lt;code&gt;client&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;group&lt;/code&gt;, &lt;code&gt;amr&lt;/code&gt;) and which names services assert (&lt;code&gt;time&lt;/code&gt;, &lt;code&gt;operation&lt;/code&gt;, &lt;code&gt;resource&lt;/code&gt;) must never overlap. Write it down and version it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Checks that bind the token
&lt;/h2&gt;

&lt;p&gt;Beyond expiry and audience, the minter supports one more check: &lt;strong&gt;certificate binding&lt;/strong&gt;, the Biscuit equivalent of &lt;a href="https://www.rfc-editor.org/rfc/rfc8705" rel="noopener noreferrer"&gt;RFC 8705&lt;/a&gt;'s &lt;code&gt;cnf.x5t#S256&lt;/code&gt;. If the processor policy supplies a &lt;code&gt;cnf_x5t_s256&lt;/code&gt; attribute, the authority block gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check if tls_client_cert_sha256($c), $c == "&amp;lt;thumbprint&amp;gt;";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service adds &lt;code&gt;tls_client_cert_sha256(...)&lt;/code&gt; from its mTLS handshake. A stolen token presented over a different client certificate fails the check. The &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens/blob/main/scripts/demo.sh" rel="noopener noreferrer"&gt;standalone demo&lt;/a&gt; covers all three cases: no certificate, the wrong certificate, and the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Getting the output right: what I disassembled
&lt;/h2&gt;

&lt;p&gt;A generator returns a &lt;code&gt;SecurityToken&lt;/code&gt;. For anything that isn't SAML, that means a &lt;code&gt;BinarySecurityToken&lt;/code&gt;, an XML-era type with "encoded data" and an encoding type. The SDK doesn't document what the OAuth token exchange endpoint does with it. Would PingFederate base64 the data again? Decode it? Wrap it?&lt;/p&gt;

&lt;p&gt;So I disassembled the class that builds the token exchange response in PingFederate 12.3.3, &lt;code&gt;TokenGeneratorOutputGenerationStrategy&lt;/code&gt;, with &lt;code&gt;javap -c&lt;/code&gt;. It does three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;returns &lt;code&gt;BinarySecurityToken.getEncodedData()&lt;/code&gt; &lt;strong&gt;verbatim&lt;/strong&gt; as &lt;code&gt;access_token&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;sets &lt;code&gt;token_type&lt;/code&gt; to &lt;code&gt;N_A&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;computes &lt;code&gt;expires_in&lt;/code&gt; from &lt;code&gt;getExpiryDate()&lt;/code&gt; if one is set.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's good news: the Biscuit's native base64url serialization passes through untouched, and verifiers can read it directly. The generator sets the encoding type and the expiry date so &lt;code&gt;expires_in&lt;/code&gt; is correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;BinarySecurityToken&lt;/span&gt; &lt;span class="n"&gt;bst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BinarySecurityToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;XmlIDUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createID&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="no"&gt;TOKEN_TYPE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;bst&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setEncodingType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BinarySecurityToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BASE64_URL_ENCODING_TYPE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;bst&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setEncodedData&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="n"&gt;bst&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCreatedDate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="n"&gt;bst&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setExpiryDate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minted&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bst&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same mint also logs the authority block's &lt;strong&gt;revocation ID&lt;/strong&gt; along with the subject. Revoking that ID revokes the token &lt;em&gt;and every narrowed copy made from it&lt;/em&gt;, because each appended block chains from the authority block. Logging it at mint time is what makes revocation possible later.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Keys and rotation
&lt;/h2&gt;

&lt;p&gt;The root key is Ed25519, stored in an &lt;strong&gt;encrypted&lt;/strong&gt; configuration field, so PingFederate replicates it across a cluster like any other secret. The plugin's jar is also a CLI, so you can generate the key with the same code that uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-jar&lt;/span&gt; pf.plugins.biscuit-token-generator.jar keygen
&lt;span class="c"&gt;# private=49309A02…&lt;/span&gt;
&lt;span class="c"&gt;# public=ed25519/0155b736…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;Root Key ID&lt;/strong&gt; goes into each token. Verifiers look up the public key by that ID, which is how you rotate: publish the new public key, switch the generator to the new key ID, then retire the old key once no unexpired tokens use it.&lt;/p&gt;

&lt;p&gt;One biscuit-java detail cost me a test run: &lt;code&gt;Biscuit.from_b64url(token, publicKey)&lt;/code&gt; &lt;strong&gt;drops&lt;/strong&gt; &lt;code&gt;root_key_id&lt;/code&gt;. The overload that takes a &lt;code&gt;KeyDelegate&lt;/code&gt; receives the ID, which is the one you want for rotation anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Packaging: shading, and building against the image
&lt;/h2&gt;

&lt;p&gt;Two practical problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classpath clashes.&lt;/strong&gt; PingFederate ships its own vavr (0.10.2) and protobuf, and biscuit-java needs slightly different versions. The plugin is a shaded jar with every dependency relocated under &lt;code&gt;com.darkedges.pingfederate.biscuit.shaded.*&lt;/code&gt;, so it can't collide with the server's classpath.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the SDK comes from.&lt;/strong&gt; The SDK jars aren't on Maven Central, so the &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens/blob/main/docker/pingfederate/Dockerfile" rel="noopener noreferrer"&gt;Dockerfile&lt;/a&gt; takes them &lt;strong&gt;from the PingFederate image itself&lt;/strong&gt;. A build stage copies &lt;code&gt;/opt/server/server/default/lib&lt;/code&gt; out of &lt;code&gt;pingidentity/pingfederate:2511-12.3.3&lt;/code&gt;, compiles and tests the plugin on JDK 11, and the final stage copies the jar into &lt;code&gt;deploy/&lt;/code&gt;. The plugin always compiles against exactly the version it runs on.&lt;/p&gt;

&lt;p&gt;There's one runtime gotcha with the Ping Docker images. The server runs from &lt;code&gt;/opt/out/instance&lt;/code&gt;, and the startup hooks copy &lt;code&gt;/opt/server&lt;/code&gt; into it &lt;strong&gt;only on a fresh volume&lt;/strong&gt;. Rebuild the image while the volume persists and you're still running the old jar. The repo's &lt;code&gt;make redeploy&lt;/code&gt; wipes the volume for that reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Tests and an interop check
&lt;/h2&gt;

&lt;p&gt;The plugin has two test classes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;BiscuitMinterTest&lt;/code&gt;: authority facts, expiry, wrong audience, wrong key, attenuation, injection, the reserved-name refusal, and certificate binding.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BiscuitTokenGeneratorTest&lt;/code&gt;: drives the plugin through PingFederate's own SDK types, &lt;code&gt;Configuration&lt;/code&gt;, &lt;code&gt;TokenContext&lt;/code&gt;, &lt;code&gt;AttributeValue&lt;/code&gt; and &lt;code&gt;BinarySecurityToken&lt;/code&gt;, and verifies the output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two findings from writing them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;biscuit-java's default authorizer budget is 5 ms.&lt;/strong&gt; A cold JVM blows through that on the first evaluation and throws a &lt;code&gt;Timeout&lt;/code&gt;. Pass &lt;code&gt;RunLimits&lt;/code&gt; explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SDK test needs PingFederate internals.&lt;/strong&gt; &lt;code&gt;XmlIDUtil.createID()&lt;/code&gt; starts PingFederate's internal registry, which uses reflection that JDK 17+ blocks without &lt;code&gt;--add-opens&lt;/code&gt;. I run the tests on JDK 11, as the Docker build does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interop check matters most: &lt;strong&gt;tokens minted by biscuit-java in PingFederate verify in biscuit-auth 6 (Rust)&lt;/strong&gt; using only the public key. That's what Part 3's resource server uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;The plugin is half the story. In Part 3 we configure PingFederate around it with Terraform: the login, the JWT token manager, the processor policy and the generator mapping. Two resources need the generic &lt;code&gt;restapi&lt;/code&gt; provider, because the PingFederate provider has no resource for token generators. Then we test it end to end in a browser.&lt;/p&gt;

</description>
      <category>pingfederate</category>
      <category>java</category>
      <category>security</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Who mints the first Biscuit? PingFederate, token exchange and a hybrid model</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:51:04 +0000</pubDate>
      <link>https://dev.to/darkedges/who-mints-the-first-biscuit-pingfederate-token-exchange-and-a-hybrid-model-m15</link>
      <guid>https://dev.to/darkedges/who-mints-the-first-biscuit-pingfederate-token-exchange-and-a-hybrid-model-m15</guid>
      <description>&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens" rel="noopener noreferrer"&gt;darkedges/pf12.3-biscuit-datalog-tokens&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://dev.to/darkedges/delegating-authority-across-companies-with-biscuit-tokens-5583"&gt;Biscuit Trust Lab&lt;/a&gt; series, Alice asked PlannerCo to do some work. PlannerCo split it between ResearchCo and ComputeCo, ResearchCo passed part of it to SearchCo, and every hop narrowed the token it was handed. Nobody called home to ask permission, and nobody could widen what they'd been given.&lt;/p&gt;

&lt;p&gt;There was one thing that series never answered: &lt;strong&gt;where did Alice's first token come from?&lt;/strong&gt; In the lab, the platform minted it. In a real company, Alice doesn't exist until an identity provider says she does. She logs in with a password and probably MFA, against a directory the security team runs, through an IdP that every application already trusts.&lt;/p&gt;

&lt;p&gt;This series is about closing that gap with PingFederate. Over four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who mints the first Biscuit?&lt;/strong&gt; Why a hybrid of JWTs and Biscuits works, and how OAuth token exchange joins them (this article).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building a PingFederate token generator that mints Biscuits&lt;/strong&gt;: the SDK plugin, the Datalog design rules, and what I had to disassemble to get the output right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuring the exchange with Terraform and testing it end to end&lt;/strong&gt;: 17 resources, two of which the provider can't manage, and a demo app that shows every request and response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step-up MFA, attenuation, and what's honestly not done&lt;/strong&gt;, including a library gap that stops PingFederate from being the MFA attestor today.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Two token models, two strengths
&lt;/h2&gt;

&lt;p&gt;If you've read the Trust Lab, you know what Biscuits are good at. If you've run an IdP, you know what JWTs are good at. The problem is that they're good at different things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;JWT access token from your IdP&lt;/th&gt;
&lt;th&gt;Biscuit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who can narrow it&lt;/td&gt;
&lt;td&gt;Only the issuer, by minting a new one&lt;/td&gt;
&lt;td&gt;Anyone holding it, offline, by appending a block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can a holder widen it?&lt;/td&gt;
&lt;td&gt;No (it's signed)&lt;/td&gt;
&lt;td&gt;No (appended blocks can only add checks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization logic&lt;/td&gt;
&lt;td&gt;Claims, interpreted by each service's code&lt;/td&gt;
&lt;td&gt;Datalog policy, evaluated with the token's facts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delegation across hops&lt;/td&gt;
&lt;td&gt;Token exchange back at the IdP for each hop&lt;/td&gt;
&lt;td&gt;Append and pass on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who understands it&lt;/td&gt;
&lt;td&gt;Everything: gateways, PingAccess, API managers, every OAuth library&lt;/td&gt;
&lt;td&gt;Biscuit libraries only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login, MFA, consent, client registration&lt;/td&gt;
&lt;td&gt;Built into the IdP&lt;/td&gt;
&lt;td&gt;Not its job&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither column wins. JWTs are what the outside world speaks: browsers, mobile apps, API gateways, and the edge of your network all expect OAuth. Biscuits are better once the token is inside, moving between services or companies that need to narrow it as it goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hybrid: JWTs at the edge, Biscuits inside
&lt;/h2&gt;

&lt;p&gt;So don't choose. Let each format do what it's good at, and put a well-defined seam between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                north-south: standard OAuth                       east-west: capabilities
 Browser ──login──▶ PingFederate ──JWT──▶ client / gateway ──Biscuit──▶ service A ──narrowed Biscuit──▶ service B
                         ▲                     │
                         └── token exchange ───┘
                             (JWT in, Biscuit out)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seam is &lt;strong&gt;OAuth 2.0 Token Exchange, &lt;a href="https://www.rfc-editor.org/rfc/rfc8693" rel="noopener noreferrer"&gt;RFC 8693&lt;/a&gt;&lt;/strong&gt;. The client presents the JWT it already has as the &lt;code&gt;subject_token&lt;/code&gt; and asks for a different kind of token back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'https://localhost:9031/as/token.oauth2'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"orders-web:&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'grant_type=urn:ietf:params:oauth:grant-type:token-exchange'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"subject_token=&lt;/span&gt;&lt;span class="nv"&gt;$JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'subject_token_type=urn:ietf:params:oauth:token-type:access_token'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'requested_token_type=urn:darkedges:params:oauth:token-type:biscuit'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And PingFederate answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CAESiwMKoAIKBmlzc3VlcgoWaHR0cHM6Ly9sb2NhbGhvc3Q6OTAzMQoFYWxp…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issued_token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"urn:darkedges:params:oauth:token-type:biscuit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"N_A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;299&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;access_token&lt;/code&gt; is a Biscuit, signed with an Ed25519 key that PingFederate holds. &lt;code&gt;token_type: N_A&lt;/code&gt; is RFC 8693's way of saying "this isn't an OAuth access token in the usual sense". The requested token type is a URN I made up. RFC 8693 lets you define your own, and PingFederate routes on it.&lt;/p&gt;

&lt;p&gt;Here's what's inside, its &lt;strong&gt;authority block&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;issuer("https://localhost:9031");
user("alice");
client("orders-web");
scope("orders:read");
scope("orders:write");
check if time($t), $t &amp;lt;= 2026-10-04T11:23:13Z;
check if audience($a), {"orders-api"}.contains($a);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything in it came from PingFederate: the user it authenticated, the client it registered, the scopes it granted, the lifetime and audience policy it enforces. From here on, the Trust Lab mechanics apply unchanged: append a block, narrow it, pass it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works as a hybrid
&lt;/h2&gt;

&lt;p&gt;It's tempting to see token exchange as a workaround. I think it's the right design, for five reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. PingFederate stays the authority.&lt;/strong&gt; Login, MFA, account lockout, consent, client registration, scope policy and auditing all stay where they are. The Biscuit doesn't replace any of it. It carries the &lt;em&gt;result&lt;/em&gt; of it, signed by the same IdP that produced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Nothing at the edge has to change.&lt;/strong&gt; Browsers, mobile apps, PingAccess and your API gateway keep speaking standard OAuth. Only the components that want offline narrowing ever see a Biscuit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. PingFederate decides what goes into the root token.&lt;/strong&gt; Token exchange runs through a &lt;em&gt;processor policy&lt;/em&gt; in PingFederate. It validates the incoming JWT, picks the attributes it trusts (subject, client, scope), and can refuse to issue at all. That's the hook for central governance: the set of facts in the authority block is controlled in one place, not by whoever builds a client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Below the seam, delegation stops costing a round trip.&lt;/strong&gt; With JWTs only, each hop that wants a narrower token has to go back to the IdP and exchange again. With a Biscuit, the hop appends &lt;code&gt;check if operation("read")&lt;/code&gt; and moves on. PingFederate is consulted once, at the seam.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. It answers the Trust Lab's production list.&lt;/strong&gt; Part 4 of the Trust Lab ended with what a production version would need, including &lt;em&gt;persistent keys with rotation&lt;/em&gt; and &lt;em&gt;revocation&lt;/em&gt;. An IdP already has key management. Here the Biscuit carries a &lt;code&gt;root_key_id&lt;/code&gt; so verifiers can pick the right key during rotation, and every mint logs the authority block's revocation ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bridging back to the Trust Lab
&lt;/h2&gt;

&lt;p&gt;Put Alice's company IdP in front of the Trust Lab scenario and the chain becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Alice logs in to PingFederate at her company and gets a JWT, like any other OAuth app.&lt;/li&gt;
&lt;li&gt;PlannerCo, a registered OAuth client, exchanges that JWT for a Biscuit. The authority block says &lt;code&gt;user("alice")&lt;/code&gt;, &lt;code&gt;client("plannerco")&lt;/code&gt;, and the scopes Alice's company allows.&lt;/li&gt;
&lt;li&gt;PlannerCo attenuates and delegates to ResearchCo and ComputeCo, exactly as in &lt;a href="https://dev.to/darkedges/attenuating-biscuit-tokens-and-signing-the-delegation-chain-5a2f"&gt;Part 2 of the Trust Lab&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Each receiver checks the chain with PingFederate's &lt;strong&gt;public key&lt;/strong&gt; and its own policy, like the &lt;a href="https://dev.to/darkedges/local-policy-shared-facts-the-receivers-datalog-authorizer-2beg"&gt;receiver's authorizer in Part 3&lt;/a&gt;. None of them needs to call PingFederate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hands-on parts of this series use a smaller example from the repository so you can run it: users &lt;code&gt;alice&lt;/code&gt; and &lt;code&gt;bob&lt;/code&gt;, a client called &lt;code&gt;orders-web&lt;/code&gt;, and an &lt;code&gt;orders-api&lt;/code&gt; that lets you read with &lt;code&gt;orders:read&lt;/code&gt; but only write with &lt;code&gt;orders:write&lt;/code&gt; &lt;em&gt;plus&lt;/em&gt; an MFA attestation. The mechanics are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's inside PingFederate
&lt;/h2&gt;

&lt;p&gt;Token exchange in PingFederate is a small pipeline. It's worth seeing the parts before we build one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject_token (JWT)
   │
   ▼
Token processor ............ validates the JWT with the access token manager that issued it
   │
   ▼
Processor policy ........... picks subject, client_id, scope; can reject
   │
   ▼
Generator mapping .......... maps policy attributes onto the generator's contract
   │
   ▼
Token generator ............ ← this is the plugin: turns attributes into a Biscuit
   ▲
Generator group ............ routes requested_token_type=…:biscuit to the generator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything except the generator ships with PingFederate. The generator is a Java plugin written against the PingFederate SDK's &lt;code&gt;TokenGenerator&lt;/code&gt; interface, and that's the subject of the next part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't do
&lt;/h2&gt;

&lt;p&gt;I'd rather say this up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PingFederate can mint the root token, but it can't yet sign the MFA attestation block.&lt;/strong&gt; The Java Biscuit library is a format version behind the Rust one for third-party blocks. Part 4 covers this and the workaround.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PingFederate can't introspect a narrowed Biscuit meaningfully.&lt;/strong&gt; It can check the signature, but whether a request is &lt;em&gt;authorized&lt;/em&gt; depends on facts only the receiving service has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Off-the-shelf gateways don't understand Biscuits.&lt;/strong&gt; That's why the hybrid keeps JWTs at the edge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;In Part 2 we build the token generator: the SDK interface, how attributes become Datalog facts without injection, which fact names a minter must refuse to issue, and the detail I only found by disassembling PingFederate's token exchange code.&lt;/p&gt;

&lt;p&gt;If you want to jump ahead, the &lt;a href="https://github.com/darkedges/pf12.3-biscuit-datalog-tokens#step-by-step-deploy-and-test" rel="noopener noreferrer"&gt;README&lt;/a&gt; gets you from &lt;code&gt;git clone&lt;/code&gt; to a PingFederate-minted Biscuit in about ten commands.&lt;/p&gt;

</description>
      <category>pingfederate</category>
      <category>oauth</category>
      <category>security</category>
      <category>authorization</category>
    </item>
    <item>
      <title>Credits without a mutable token: reservations, idempotency and receipts</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:23:20 +0000</pubDate>
      <link>https://dev.to/darkedges/credits-without-a-mutable-token-reservations-idempotency-and-receipts-3l2a</link>
      <guid>https://dev.to/darkedges/credits-without-a-mutable-token-reservations-idempotency-and-receipts-3l2a</guid>
      <description>&lt;p&gt;The first three parts of this series covered authority: who may do what, on whose behalf, and who decides. This last part covers money.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;
        biscuit-trust-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      An interactive Cloudflare Pages playground for exploring Biscuit delegation, Datalog authorization, trust between independent operators, and shared clearinghouse billing.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — Biscuit network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A Cloudflare Pages demonstration of independent operators accepting delegated tasks, applying local Datalog policies, and billing the original payer through a shared clearinghouse. Biscuit tokens and Ed25519 signatures are real; operators, work, and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://biscuits.demos.darkedges.com/#biscuit" rel="nofollow noopener noreferrer"&gt;biscuits.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/biscuit-trust-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Fbiscuit-trust-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay playground showing the agent network, credit ledger, granted permissions, and Biscuit decision logic"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8795" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8795&lt;/a&gt;&lt;/strong&gt;. On this development computer, &lt;code&gt;pnpm dev:lan&lt;/code&gt; binds to &lt;code&gt;10.0.0.35:8795&lt;/code&gt; for other devices on the same network. Change that address in &lt;code&gt;package.json&lt;/code&gt; if your computer has a different LAN IP. The interface starts a demo run automatically.&lt;/p&gt;
&lt;p&gt;On Windows, &lt;code&gt;./start.ps1&lt;/code&gt; starts the Pages preview and installs dependencies if needed. Use &lt;code&gt;./start.ps1 -BindAddress 10.0.0.35&lt;/code&gt; for LAN access.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Deploy to Cloudflare Pages&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The Pages output directory is &lt;code&gt;web&lt;/code&gt;, as declared in &lt;code&gt;wrangler.jsonc&lt;/code&gt;. &lt;code&gt;web/_worker.js&lt;/code&gt; handles &lt;code&gt;/api/run&lt;/code&gt; and &lt;code&gt;/api/health&lt;/code&gt; and forwards other requests to Pages assets. Dependencies, including Biscuit WebAssembly, are bundled…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Why the balance isn't in the token
&lt;/h2&gt;

&lt;p&gt;It's tempting to put &lt;code&gt;budget(100)&lt;/code&gt; in the Biscuit and have each hop append &lt;code&gt;spent(5)&lt;/code&gt;. That doesn't work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tokens are immutable and copyable.&lt;/strong&gt; Two holders of the same token can each spend "the remaining 95". Nothing in the token stops concurrent use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appended blocks can't change facts.&lt;/strong&gt; A later block can't reduce an earlier balance. It can only add checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spending is shared state.&lt;/strong&gt; Search and compute run in parallel under the same request, so the shared budget has to live in one place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the token carries &lt;strong&gt;who pays and for which request&lt;/strong&gt; (&lt;code&gt;payer&lt;/code&gt;, &lt;code&gt;request_id&lt;/code&gt;). A &lt;strong&gt;clearinghouse&lt;/strong&gt; holds the ledger for that request. Authorization is local, and accounting is coordinated. That's the line in the footer of the demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve → execute → settle (or release)
&lt;/h2&gt;

&lt;p&gt;Each billable call goes through the same lifecycle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The operator authorizes the call (part 3).&lt;/li&gt;
&lt;li&gt;The clearinghouse authorizes it again, then &lt;strong&gt;reserves&lt;/strong&gt; credits and returns a signed grant.&lt;/li&gt;
&lt;li&gt;The operator does the work.&lt;/li&gt;
&lt;li&gt;The operator signs a &lt;strong&gt;completion receipt&lt;/strong&gt; bound to that grant, and the clearinghouse &lt;strong&gt;settles&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If the work fails, the clearinghouse &lt;strong&gt;releases&lt;/strong&gt; the reservation instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reserved and settled credits both count against the budget, so promised money can't be promised twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serializing reservations in single-threaded JavaScript
&lt;/h2&gt;

&lt;p&gt;JavaScript is single-threaded, so it might seem that a race can't happen. But &lt;code&gt;reserveLocked&lt;/code&gt; contains &lt;code&gt;await&lt;/code&gt;s (SHA-256 fingerprinting and signing the grant) between reading the used total and recording the reservation. Two reservations can interleave at those points and both see the same available balance.&lt;/p&gt;

&lt;p&gt;The lab chains reservations onto a promise queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reservationQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserveLocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reservationQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;reserveLocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... payer/request/amount validation, idempotency (below)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;released&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InsufficientCredits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;available_before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reserved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;grant&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reserved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.catch(() =&amp;gt; {})&lt;/code&gt; on the queue tail means one rejected reservation doesn't block the ones after it. The caller still receives the rejection through &lt;code&gt;pending&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The competing-reservations scenario
&lt;/h3&gt;

&lt;p&gt;With a 100-credit budget, research settles 5. Search and compute then each ask for 60 &lt;strong&gt;concurrently&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;researchProof&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;searchProof&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;computeToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;computeProof&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;compute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever reaches the queue first gets its 60. The other sees &lt;code&gt;Insufficient credits: 60 requested, 35 available&lt;/code&gt; and is denied &lt;em&gt;after&lt;/em&gt; passing Biscuit authorization. The decision view says so explicitly: "Biscuit allowed the call; the clearinghouse denied its credit reservation." Raise the budget to 125 and both succeed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency: retries don't double-charge
&lt;/h2&gt;

&lt;p&gt;Networks retry. The clearinghouse keys reservations by &lt;code&gt;operation_id&lt;/code&gt; and fingerprints the full signed payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency conflict: operation ID reused with different billing details&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;released&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This operation was released; create a new operation ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same ID, same details.&lt;/strong&gt; Return the existing grant. If it's already settled, the operator returns the existing result, with no new work and no new charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same ID, different details.&lt;/strong&gt; Conflict. Someone is trying to reuse an operation ID to change the amount, payer or action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same ID after release.&lt;/strong&gt; Refuse. A failed operation needs a fresh ID, so a stale retry can't revive it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;strong&gt;Retry&lt;/strong&gt; scenario sends the search call twice. The ledger shows 3 operations and 40 credits settled, not 50.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receipts: settle only what the provider claims it completed
&lt;/h2&gt;

&lt;p&gt;Settlement needs evidence from the provider, bound to the specific reservation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;grant_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proof&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identities&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Receipt does not prove completion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operation_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;proof&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grant_hash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Receipt is not bound to this provider and reservation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;released&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cannot settle a released reservation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A receipt from the wrong provider, for another reservation, or for a released reservation is rejected.&lt;/p&gt;

&lt;p&gt;Be clear about what a receipt proves. It's the provider's &lt;strong&gt;signed claim&lt;/strong&gt; that it completed the work. It doesn't prove the work was any good. That's a matter for disputes and reputation, not for cryptography.&lt;/p&gt;

&lt;h3&gt;
  
  
  The operation-failure scenario
&lt;/h3&gt;

&lt;p&gt;Search reserves 10 credits, and then its simulated execution fails. The clearinghouse releases the reservation. Settled is 30, available is 70, and replaying the timeline shows &lt;code&gt;reserved&lt;/code&gt; rising to 10 and falling back to 0. A test checks that every replay snapshot balances:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;available&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;settled&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Running it on Cloudflare Pages
&lt;/h2&gt;

&lt;p&gt;The lab deploys as a single Pages project. &lt;code&gt;web/_worker.js&lt;/code&gt; (advanced mode) handles &lt;code&gt;/api/run&lt;/code&gt; and &lt;code&gt;/api/health&lt;/code&gt; and passes everything else to static assets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/run&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Request is too large&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;413&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runDemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request creates fresh keys, a new request ID and a request-local ledger. That keeps the demo stateless. It also means &lt;strong&gt;nothing persists between requests or across Cloudflare locations&lt;/strong&gt;, which is fine for a lab and wrong for real money.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;install
&lt;/span&gt;pnpm check          &lt;span class="c"&gt;# node --test: real Biscuit decisions, concurrency, idempotency, release&lt;/span&gt;
pnpm run deploy     &lt;span class="c"&gt;# wrangler pages deploy web&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What a production version would need
&lt;/h2&gt;

&lt;p&gt;The lab deliberately stops short of being a payment system. To make it real you would need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Durable, centralized accounting.&lt;/strong&gt; The serialized queue would become a transactional store, for example a Durable Object or a database row lock for each request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent issuer and operator keys,&lt;/strong&gt; with rotation and published key sets, instead of fresh keys on every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authenticated transports&lt;/strong&gt; between operators, not in-process function calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payer consent and quotes&lt;/strong&gt; before a reservation, not a fixed price list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation.&lt;/strong&gt; Biscuit revocation identifiers make this possible, but someone has to publish and check the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reservation expiry,&lt;/strong&gt; so abandoned reservations free their credits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation and dispute handling,&lt;/strong&gt; because a receipt is a claim, not proof of quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Across the four parts, we covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;Biscuit&lt;/strong&gt; root grant whose authority block binds requester, payer, request and rights, and whose checks every use must pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attenuation&lt;/strong&gt; at each hop, narrowing what downstream operators can do without contacting the issuer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed delegation envelopes&lt;/strong&gt; that fill in what Biscuit doesn't record: who delegated to whom.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;local Datalog policy&lt;/strong&gt; at each receiver, combining issuer facts, verified call facts and its own trust settings.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;clearinghouse&lt;/strong&gt; that keeps money out of the token, with serialized reservations, idempotent retries, releases and receipt-bound settlement.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Try to break it. Change the trust graph, swap payers, squeeze the budget, and watch the Debug tab. If you find a way to get SearchCo to do work it shouldn't, please open an issue.&lt;/p&gt;

&lt;p&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;github.com/darkedges/biscuit-trust-lab&lt;/a&gt;&lt;/strong&gt; · Demo: &lt;strong&gt;&lt;a href="https://biscuits.demos.darkedges.com/#biscuit" rel="noopener noreferrer"&gt;biscuits.demos.darkedges.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>distributedsystems</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Local policy, shared facts: the receiver's Datalog authorizer</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:23:03 +0000</pubDate>
      <link>https://dev.to/darkedges/local-policy-shared-facts-the-receivers-datalog-authorizer-2beg</link>
      <guid>https://dev.to/darkedges/local-policy-shared-facts-the-receivers-datalog-authorizer-2beg</guid>
      <description>&lt;p&gt;So far in this series we've minted a root grant, narrowed it at each hop, and wrapped every delegation in a signed envelope. All of that tells the receiver what was delegated and by whom. It doesn't decide whether the receiver should do the work. That decision belongs to the receiver alone.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Biscuit Trust Lab&lt;/a&gt;, each operator makes that decision with its own Biscuit authorizer.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/darkedges" rel="noopener noreferrer"&gt;
        darkedges
      &lt;/a&gt; / &lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;
        biscuit-trust-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      An interactive Cloudflare Pages playground for exploring Biscuit delegation, Datalog authorization, trust between independent operators, and shared clearinghouse billing.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Relay — Biscuit network lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A Cloudflare Pages demonstration of independent operators accepting delegated tasks, applying local Datalog policies, and billing the original payer through a shared clearinghouse. Biscuit tokens and Ed25519 signatures are real; operators, work, and credits are simulated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://biscuits.demos.darkedges.com/#biscuit" rel="nofollow noopener noreferrer"&gt;biscuits.demos.darkedges.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/darkedges/biscuit-trust-lab/docs/playground-screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdarkedges%2Fbiscuit-trust-lab%2FHEAD%2Fdocs%2Fplayground-screenshot.png" alt="Relay playground showing the agent network, credit ledger, granted permissions, and Biscuit decision logic"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run locally&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Use Node.js 22 or newer and pnpm 11:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pnpm install
pnpm dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:8795" rel="nofollow noopener noreferrer"&gt;http://127.0.0.1:8795&lt;/a&gt;&lt;/strong&gt;. On this development computer, &lt;code&gt;pnpm dev:lan&lt;/code&gt; binds to &lt;code&gt;10.0.0.35:8795&lt;/code&gt; for other devices on the same network. Change that address in &lt;code&gt;package.json&lt;/code&gt; if your computer has a different LAN IP. The interface starts a demo run automatically.&lt;/p&gt;
&lt;p&gt;On Windows, &lt;code&gt;./start.ps1&lt;/code&gt; starts the Pages preview and installs dependencies if needed. Use &lt;code&gt;./start.ps1 -BindAddress 10.0.0.35&lt;/code&gt; for LAN access.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Deploy to Cloudflare Pages&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The Pages output directory is &lt;code&gt;web&lt;/code&gt;, as declared in &lt;code&gt;wrangler.jsonc&lt;/code&gt;. &lt;code&gt;web/_worker.js&lt;/code&gt; handles &lt;code&gt;/api/run&lt;/code&gt; and &lt;code&gt;/api/health&lt;/code&gt; and forwards other requests to Pages assets. Dependencies, including Biscuit WebAssembly, are bundled…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  One policy, four sources of facts
&lt;/h2&gt;

&lt;p&gt;Every operator runs the same policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;requester&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;accepts_requester&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;trusts_caller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;billing_payer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;current_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the same rule everywhere, but each operator evaluates it against different facts. Every predicate has a clear origin:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fact&lt;/th&gt;
&lt;th&gt;Comes from&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;requester&lt;/code&gt;, &lt;code&gt;payer&lt;/code&gt;, &lt;code&gt;request_id&lt;/code&gt;, &lt;code&gt;right&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Biscuit authority block&lt;/strong&gt; (signed by the clearinghouse)&lt;/td&gt;
&lt;td&gt;What the original grant says&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;caller&lt;/code&gt;, &lt;code&gt;billing_payer&lt;/code&gt;, &lt;code&gt;current_request&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Verified call&lt;/strong&gt; (caller's Ed25519 signature checked)&lt;/td&gt;
&lt;td&gt;What this call asks for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;service&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Receiving operator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Who I am&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;accepts_requester&lt;/code&gt;, &lt;code&gt;trusts_caller&lt;/code&gt;, &lt;code&gt;offers&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Receiver's local policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Whom I serve, whom I trust, what I do&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Restrictions appended during delegation don't add facts. They add &lt;code&gt;check if&lt;/code&gt; rules that must also pass (see part 2).&lt;/p&gt;

&lt;p&gt;The policy reads almost like English. Allow the call if the original requester is someone I accept, the caller is someone I trust to send me work, the payer on the call is the payer on the grant, the call belongs to the granted request, and the action was granted and is something I offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the authorizer
&lt;/h2&gt;

&lt;p&gt;Here's how the receiver assembles the authorizer in &lt;code&gt;lib/relay.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AuthorizerBuilder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;POLICY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// The policy is fixed source code. All added values come from validated names or JSON-quoted strings.&lt;/span&gt;
&lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`caller(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;); service(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;);
  billing_payer(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;);
  current_request(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;); action(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;);
  offers(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;);`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authorizer&lt;/span&gt;&lt;span class="s2"&gt;`time(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;);`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;who&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`accepts_requester(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;);`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`trusts_caller(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;);`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;built&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buildAuthenticated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;built&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorizeWithLimits&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;max_facts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_iterations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_time_micro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;buildAuthenticated(verified)&lt;/code&gt;&lt;/strong&gt; only runs on a token that has already passed root-key verification and the chain checks from part 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;authorizeWithLimits&lt;/code&gt;&lt;/strong&gt; caps facts, iterations and time. Every authorizer that evaluates input from outside should do this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust is local data.&lt;/strong&gt; &lt;code&gt;trusts_caller&lt;/code&gt; and &lt;code&gt;accepts_requester&lt;/code&gt; come from each operator's own settings. In the UI you edit them in the trust graph and the requester checkboxes. The token never carries them.&lt;/li&gt;
&lt;li&gt;The lab keeps &lt;code&gt;offers&lt;/code&gt; simple: each operator offers exactly one action with the same name as itself (&lt;code&gt;SearchCo&lt;/code&gt; offers &lt;code&gt;search&lt;/code&gt;). A real operator would list its catalogue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reading the decision
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;authorize()&lt;/code&gt; returns allow or deny. For teaching, that isn't enough, so the lab asks Biscuit for the facts the authorizer can actually see, using a query rule for each predicate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`inspected(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) &amp;lt;- &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;built&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;queryWithLimits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LIMITS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fact&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then compares the facts the call needed with the facts that are present, and labels each with its origin. That table is what you see when you click an operator under &lt;strong&gt;Granted permissions&lt;/strong&gt;. A missing fact shows in red.&lt;/p&gt;

&lt;p&gt;One caveat: the table is an &lt;em&gt;explanation&lt;/em&gt;. Biscuit's own &lt;code&gt;authorize()&lt;/code&gt; result is the only thing that decides permit or deny. The table is rebuilt from queried facts so a human can see why.&lt;/p&gt;

&lt;h2&gt;
  
  
  How each scenario fails
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Change payer
&lt;/h3&gt;

&lt;p&gt;ResearchCo signs a search call that names &lt;code&gt;mallory-org&lt;/code&gt; as the payer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;payer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice-org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;// authority block&lt;/span&gt;
&lt;span class="nf"&gt;billing_payer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mallory-org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// from the verified call&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fails twice. The policy needs &lt;code&gt;payer($p), billing_payer($p)&lt;/code&gt; to unify, and they don't. The root token's own &lt;code&gt;check if billing_payer($p), payer($p)&lt;/code&gt; fails too. Even an operator with a careless policy would be stopped by the issuer's check. Search is denied before billing, so the ledger settles 30 instead of 40.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forbidden operation
&lt;/h3&gt;

&lt;p&gt;SearchCo receives &lt;code&gt;action("delete")&lt;/code&gt;. The grant has no &lt;code&gt;right("delete")&lt;/code&gt;, and SearchCo doesn't offer delete. ResearchCo's block &lt;code&gt;check if action("search")&lt;/code&gt; also fails. Three independent reasons, one denial.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing caller trust
&lt;/h3&gt;

&lt;p&gt;Remove the &lt;code&gt;ResearchCo → SearchCo&lt;/code&gt; edge in the trust graph and run again. SearchCo's authorizer no longer has &lt;code&gt;trusts_caller("research", "search")&lt;/code&gt;, so no policy matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"FailedLogic"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"NoMatchingPolicy"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ComputeCo is unaffected, because it trusts PlannerCo, which delegated compute directly. Changing trust at one operator only changes that operator's decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Requester not accepted
&lt;/h3&gt;

&lt;p&gt;Untick Alice for SearchCo, and &lt;code&gt;accepts_requester("alice")&lt;/code&gt; disappears from SearchCo's authorizer. If PlannerCo stops accepting Alice, the run stops at ingress. PlannerCo runs a small authorizer of its own before delegating anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;planner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;authorizer&lt;/span&gt;&lt;span class="s2"&gt;`allow if requester($r), accepts_requester($r);`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When that fails, no tasks are created at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Untrusted issuer
&lt;/h3&gt;

&lt;p&gt;As covered in part 2, a token minted with a different root key fails &lt;code&gt;Biscuit.fromBase64(token, rootPublic)&lt;/code&gt; and never reaches Datalog. The decision view shows &lt;strong&gt;phase: verification&lt;/strong&gt; rather than &lt;strong&gt;phase: datalog&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two authorization boundaries
&lt;/h2&gt;

&lt;p&gt;Every billable call is authorized &lt;strong&gt;twice&lt;/strong&gt;: once by the receiving operator, and again on behalf of the clearinghouse before it reserves credits. The &lt;strong&gt;Debug&lt;/strong&gt; tab shows them as separate entries. In the lab, the clearinghouse re-runs the same verification and the receiver's policy. In a real deployment it would hold its own policy, for example "only reserve for providers registered for this payer".&lt;/p&gt;

&lt;p&gt;The tests check that a denied call never reaches billing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;denied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runDemo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payer_swap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;denied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task_index&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Denied operator call must not reach billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Authorization says the call is allowed. It doesn't say the payer can afford it. In the final part we look at the clearinghouse: why the balance can't live in the token, how reservations stay safe under concurrency, how retries avoid double charging, and what a production version would need.&lt;/p&gt;

&lt;p&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;github.com/darkedges/biscuit-trust-lab&lt;/a&gt;&lt;/strong&gt; · Demo: &lt;strong&gt;&lt;a href="https://biscuits.demos.darkedges.com/#biscuit" rel="noopener noreferrer"&gt;biscuits.demos.darkedges.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>datalog</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
