<?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>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>
    <item>
      <title>Attenuating Biscuit tokens and signing the delegation chain</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:22:45 +0000</pubDate>
      <link>https://dev.to/darkedges/attenuating-biscuit-tokens-and-signing-the-delegation-chain-5a2f</link>
      <guid>https://dev.to/darkedges/attenuating-biscuit-tokens-and-signing-the-delegation-chain-5a2f</guid>
      <description>&lt;p&gt;In part 1 I introduced the network: Alice's request flows through PlannerCo to ResearchCo, SearchCo and ComputeCo, and every billable call goes through a clearinghouse. This post covers the token side: how the root grant is minted, how each hop narrows it, and why Biscuit alone isn't enough to prove &lt;em&gt;who&lt;/em&gt; delegated.&lt;/p&gt;

&lt;p&gt;All code is from &lt;a href="https://github.com/darkedges/biscuit-trust-lab/blob/main/lib/relay.js" rel="noopener noreferrer"&gt;&lt;code&gt;lib/relay.js&lt;/code&gt;&lt;/a&gt;.&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;
  
  
  Minting the root grant
&lt;/h2&gt;

&lt;p&gt;The clearinghouse issues one root Biscuit for each request. Its authority block holds facts about the request and checks that every later use must pass:&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;mint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privateKey&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="nx"&gt;biscuit&lt;/span&gt;&lt;span class="s2"&gt;`requester(&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;requester&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="s2"&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="s2"&gt;);
    right("research"); right("search"); right("compute");
    check if billing_payer($p), payer($p);
    check if current_request($r), request_id($r);
    check if action($a), right($a);
    check if time($t), $t &amp;lt; &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;expiry&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;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privateKey&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 &lt;strong&gt;facts&lt;/strong&gt; describe the grant: Alice is the requester, alice-org pays, the request has an ID, and three rights are granted.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;checks&lt;/strong&gt; tie those facts to each call. The receiver supplies &lt;code&gt;billing_payer&lt;/code&gt;, &lt;code&gt;current_request&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt; and &lt;code&gt;time&lt;/code&gt; from the verified call it's handling. If the call claims a different payer, a different request, an action outside the granted rights, or arrives after expiry, a check fails and Biscuit denies the call. The receiver's own policy can't override that.&lt;/p&gt;

&lt;p&gt;The tagged template (&lt;code&gt;biscuit`...`&lt;/code&gt;) comes from the JavaScript Biscuit bindings. Interpolated values are passed as typed parameters, not pasted into the source, so a requester name can't inject Datalog.&lt;/p&gt;

&lt;p&gt;The keys are real Ed25519 keys:&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;root&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;KeyPair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SignatureAlgorithm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Ed25519&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;rootPrivate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPrivateKey&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;rootPublic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPublicKey&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;expiry&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="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&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;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;rootToken&lt;/span&gt; &lt;span class="o"&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;mint&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;rootPrivate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each operator gets the clearinghouse's &lt;strong&gt;public&lt;/strong&gt; key and only accepts tokens whose signature chain leads back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attenuating at each hop
&lt;/h2&gt;

&lt;p&gt;When PlannerCo delegates research, it appends a block that limits which actions the token can still be used for:&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;delegate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parentToken&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;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parentTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parentProof&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`check if &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actions&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;action&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;action&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="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;])).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; or &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;restriction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="s2"&gt;``&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;restriction&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;check&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="nx"&gt;parentToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;restriction&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... signed envelope, see below&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The network makes three delegations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;Appended block&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PlannerCo → ResearchCo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if action("research") or action("search")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ResearchCo → SearchCo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if action("search")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PlannerCo → ComputeCo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;check if action("compute")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SearchCo's token therefore carries the authority block plus &lt;strong&gt;two&lt;/strong&gt; restriction blocks. Every check in every block must pass. Even though the root grants &lt;code&gt;compute&lt;/code&gt;, SearchCo can't use its token for compute, because ResearchCo's block forbids it.&lt;/p&gt;

&lt;p&gt;Appending needs no private key and no call to the issuer. Each block is chained to the one before it with a fresh ephemeral key, so any block can be added but none can be removed. In part 1 I also showed that a &lt;code&gt;right("delete")&lt;/code&gt; fact in an appended block is ignored by the authorizer. Blocks can narrow authority but can't widen it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap: who appended this block?
&lt;/h2&gt;

&lt;p&gt;Here is the problem. A Biscuit tells SearchCo which restrictions apply. It doesn't tell SearchCo who added them. An attenuation block isn't signed by an identity that SearchCo knows.&lt;/p&gt;

&lt;p&gt;That matters in a delegation network. SearchCo's policy says "I accept work from ResearchCo". To enforce that, SearchCo needs proof that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;ResearchCo is the one calling.&lt;/li&gt;
&lt;li&gt;ResearchCo got this task from PlannerCo, which got it from the original request.&lt;/li&gt;
&lt;li&gt;Each token in the chain extends the token before it, with nothing dropped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the lab adds a &lt;strong&gt;delegation envelope&lt;/strong&gt; at each hop, signed with the delegator's own Ed25519 identity key (via WebCrypto):&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;proof&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;caller&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;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;task_id&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;task&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;parent_task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parentTask&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;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;allowed_actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;token_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;tokenText&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;tokenText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;parent_proof_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parentProof&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;parentProof&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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 envelope names the delegator and the receiver, binds to the exact token by hash, and links to its parent envelope by hash. The envelopes form a hash chain alongside the token's block chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signed operation request
&lt;/h2&gt;

&lt;p&gt;When ResearchCo finally calls SearchCo, it signs the call itself:&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&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;provider&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="nx"&gt;amount&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer&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;payer&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;task_id&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;task_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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;operationId&lt;/span&gt; &lt;span class="o"&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;op&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;token_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;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBase64&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;call&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;caller&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="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call states the action, the price, and the payer, and it binds to the token by hash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the chain
&lt;/h2&gt;

&lt;p&gt;Before any Datalog runs, the receiver checks the evidence in &lt;code&gt;evaluateAuthorization&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;payload&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;caller&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;signedCall&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;token_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;tokenText&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;Signed call does not match the presented Biscuit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;previousBlocks&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;rootToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRevocationIdentifiers&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;proof&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;proofChain&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;entry&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;payload&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;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;entry&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;proof&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// envelope signature&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hopToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Biscuit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromBase64&lt;/span&gt;&lt;span class="p"&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;token&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;rootPublic&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// issuer signature&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hopToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRevocationIdentifiers&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;blocks&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;lt;=&lt;/span&gt; &lt;span class="nx"&gt;previousBlocks&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;||&lt;/span&gt;
      &lt;span class="nx"&gt;previousBlocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&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;index&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;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&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;Child Biscuit must extend the parent token without dropping restrictions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... request binding, parent hash, caller == previous provider, chain starts at planner&lt;/span&gt;
  &lt;span class="nx"&gt;previous&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revocation identifiers make the "extends" check cheap. Each block has a unique identifier, so a child token extends its parent exactly when the parent's identifiers form a strict prefix of the child's.&lt;/p&gt;

&lt;p&gt;The loop also checks that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every envelope belongs to this request;&lt;/li&gt;
&lt;li&gt;each envelope's &lt;code&gt;parent_proof_hash&lt;/code&gt; matches the previous envelope;&lt;/li&gt;
&lt;li&gt;each hop's caller is the previous hop's provider, and its &lt;code&gt;parent_task&lt;/code&gt; is the previous hop's &lt;code&gt;task_id&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the chain starts at PlannerCo, with parent &lt;code&gt;root&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the final envelope matches the signed call's caller, provider, task, request and token hash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after all of that does &lt;code&gt;Biscuit.fromBase64(tokenText, this.rootPublic)&lt;/code&gt; verify the presented token and hand it to the authorizer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The untrusted-issuer scenario
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;Untrusted issuer&lt;/strong&gt; scenario mints a lookalike token with a rogue key and delegates it to SearchCo with a correctly signed envelope:&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;rogue&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;KeyPair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SignatureAlgorithm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Ed25519&lt;/span&gt;&lt;span class="p"&gt;);&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="nx"&gt;searchProof&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;delegate&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;mint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rogue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPrivateKey&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;research&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The facts are identical. The envelope is validly signed by ResearchCo. But &lt;code&gt;Biscuit.fromBase64(entry.token, this.rootPublic)&lt;/code&gt; fails, because the token's root signature doesn't come from the clearinghouse. The call is rejected in the &lt;strong&gt;verification&lt;/strong&gt; phase. No Datalog runs, and no credits are reserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical note: Biscuit WASM in Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;The lab uses &lt;code&gt;@smithery/biscuit&lt;/code&gt;, which wraps &lt;code&gt;@biscuit-auth/biscuit-wasm&lt;/code&gt;. For Wrangler to bundle the &lt;code&gt;.wasm&lt;/code&gt; file into a Pages Worker, the WASM package has to expose its module files in &lt;code&gt;exports&lt;/code&gt;. The repo carries a small pnpm patch for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   "exports": {
&lt;span class="gd"&gt;-    "import": "./module/biscuit.js"
&lt;/span&gt;&lt;span class="gi"&gt;+    ".": { "import": "./module/biscuit.js" },
+    "./module/biscuit_bg.js": "./module/biscuit_bg.js",
+    "./module/biscuit_bg.wasm": "./module/biscuit_bg.wasm"
&lt;/span&gt;   },
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's registered under &lt;code&gt;patchedDependencies&lt;/code&gt; in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, so &lt;code&gt;pnpm install&lt;/code&gt; applies it automatically.&lt;/p&gt;

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

&lt;p&gt;The token and the chain prove &lt;strong&gt;what was delegated, and by whom&lt;/strong&gt;. In part 3 we look at how each receiver decides whether to &lt;strong&gt;accept&lt;/strong&gt; the call, using one Datalog policy that combines facts from the issuer, the call, and its own local trust settings.&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>javascript</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Delegating authority across companies with Biscuit tokens</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:22:28 +0000</pubDate>
      <link>https://dev.to/darkedges/delegating-authority-across-companies-with-biscuit-tokens-5583</link>
      <guid>https://dev.to/darkedges/delegating-authority-across-companies-with-biscuit-tokens-5583</guid>
      <description>&lt;p&gt;Alice wants a piece of work done. Her organization pays for it. She hands the job 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 doing any work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who originally asked for this, and do I accept work for them?&lt;/li&gt;
&lt;li&gt;Who is calling me, and do I trust them to send me work?&lt;/li&gt;
&lt;li&gt;Who pays, and is that the payer who actually authorized it?&lt;/li&gt;
&lt;li&gt;Is this action within what was originally granted, and within what each step passed on?&lt;/li&gt;
&lt;li&gt;Is there still money left?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A plain bearer token or API key can't answer most of these. It can say "the holder may do X". It can't say "the holder may do X, on behalf of Alice, paid by alice-org, only as part of request 42, and only the search part of it". And once the token leaves your hands, you can't narrow it without going back to the issuer.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;&lt;a href="https://github.com/darkedges/biscuit-trust-lab" rel="noopener noreferrer"&gt;Biscuit Trust Lab&lt;/a&gt;&lt;/strong&gt; to explore this problem with real cryptography 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/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;p&gt;&lt;strong&gt;Live 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;/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 (requester) / alice-org (payer)
  └─ PlannerCo (initial coordinator)
       ├─ ResearchCo [5 credits]
       │    └─ SearchCo [10 credits]
       └─ ComputeCo [25 credits]

All billable calls → shared clearinghouse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four independent operators and a clearinghouse take part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;clearinghouse&lt;/strong&gt; issues the original grant and keeps the request's credit ledger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PlannerCo&lt;/strong&gt; accepts Alice's request and delegates research and compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ResearchCo&lt;/strong&gt; does research, then delegates search to &lt;strong&gt;SearchCo&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ComputeCo&lt;/strong&gt; does compute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each operator decides for itself whether to accept a call. No central service says yes or no to everyone. The clearinghouse only coordinates the money.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What's real and what's simulated.&lt;/strong&gt; The Biscuit tokens, Datalog authorization, and Ed25519 signatures are real. The operators, the work, and the credits are simulated, and everything runs inside a single request. This is a teaching lab, not a payment system.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why Biscuit?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.biscuitsec.org/" rel="noopener noreferrer"&gt;Biscuit&lt;/a&gt; is a bearer token format with three properties that fit this problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Offline attenuation.&lt;/strong&gt; Anyone holding a Biscuit can append a block that &lt;em&gt;restricts&lt;/em&gt; it, without contacting the issuer. PlannerCo can take Alice's grant and pass ResearchCo a copy limited to research and search. ResearchCo can narrow it again to search only before passing it to SearchCo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Appended blocks can't widen authority.&lt;/strong&gt; A block can add checks that must pass. Facts it adds aren't trusted by the receiver's authorization policy. I tested this directly against the library used 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&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;KeyPair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SignatureAlgorithm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Ed25519&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="nx"&gt;biscuit&lt;/span&gt;&lt;span class="s2"&gt;`right("search");`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPrivateKey&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Try to grant ourselves "delete" from an appended block&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sneaky&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="s2"&gt;``&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;sneaky&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right("delete");&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;widened&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Biscuit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromBase64&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="nf"&gt;appendBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sneaky&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBase64&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPublicKey&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;auth&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;auth&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;action("delete"); allow if action($a), right($a);&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;auth&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;widened&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// throws: {"FailedLogic":{"NoMatchingPolicy":{"checks":[]}}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;right("delete")&lt;/code&gt; fact from the appended block is ignored. Only facts from the authority block (signed by the issuer) and from the receiver's own authorizer count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Datalog policies.&lt;/strong&gt; Each receiver writes its policy as Datalog rules. It combines facts from the token, facts about the incoming call, and its own local facts (who it trusts, which requesters it accepts, what it offers). Part 3 of this series covers the policy in detail.&lt;/p&gt;

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

&lt;p&gt;Biscuit handles "what is this token allowed to do?". A delegation network needs more than that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who appended this block?&lt;/strong&gt; A Biscuit attenuation block doesn't identify its author. The lab wraps each delegation in a separately signed &lt;strong&gt;delegation envelope&lt;/strong&gt;, so a receiver can check the whole chain: PlannerCo → ResearchCo → SearchCo. (Part 2)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who is calling right now?&lt;/strong&gt; The caller signs every operation request, and the receiver checks that signature against the presented token. (Part 2)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there money left?&lt;/strong&gt; Biscuits are immutable and don't store balances. A request-scoped clearinghouse reserves credits, prevents double spending, and settles only against a signed completion receipt. (Part 4)&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Open the &lt;a href="https://biscuits.demos.darkedges.com/#biscuit" rel="noopener noreferrer"&gt;live demo&lt;/a&gt;. It starts with an authorized run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authorized delegation&lt;/strong&gt; settles 5 credits for research, 10 for search, and 25 for compute. alice-org pays 40 in total.&lt;/li&gt;
&lt;li&gt;Open &lt;strong&gt;Granted permissions&lt;/strong&gt; to see the root rights and each restriction that was appended. Click &lt;strong&gt;SearchCo&lt;/strong&gt; to see the decision logic filled in with the real requester, payer, caller, action, and request values.&lt;/li&gt;
&lt;li&gt;Remove the &lt;code&gt;ResearchCo → SearchCo&lt;/code&gt; trust edge, or untick Alice for SearchCo, and run again. Search is denied. Compute still succeeds, because it never went through ResearchCo.&lt;/li&gt;
&lt;li&gt;Try the other scenarios:&lt;/li&gt;
&lt;/ol&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 happens&lt;/th&gt;
&lt;th&gt;Credits settled&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;Three operations settle&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change payer&lt;/td&gt;
&lt;td&gt;Search denied by a Biscuit check&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forbidden operation&lt;/td&gt;
&lt;td&gt;Search attempts &lt;code&gt;delete&lt;/code&gt; and is denied&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Untrusted issuer&lt;/td&gt;
&lt;td&gt;Search token's signature rejected&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Competing reservations&lt;/td&gt;
&lt;td&gt;One 60-credit child denied at budget 100&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry&lt;/td&gt;
&lt;td&gt;Search's settlement is reused, not charged twice&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operation failure&lt;/td&gt;
&lt;td&gt;Search's reservation is released&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;Debug&lt;/strong&gt; tab (&lt;code&gt;/#debug&lt;/code&gt;) records the browser's real &lt;code&gt;POST /api/run&lt;/code&gt; call, plus every internal authorization and clearinghouse step. You can filter for denials and copy a cURL command to reproduce a run.&lt;/p&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/biscuit-trust-lab.git
&lt;span class="nb"&gt;cd &lt;/span&gt;biscuit-trust-lab
pnpm &lt;span class="nb"&gt;install
&lt;/span&gt;pnpm dev      &lt;span class="c"&gt;# http://127.0.0.1:8795&lt;/span&gt;
pnpm check    &lt;span class="c"&gt;# runs the test suite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole thing is a Cloudflare Pages project. &lt;code&gt;web/&lt;/code&gt; holds the UI, &lt;code&gt;web/_worker.js&lt;/code&gt; serves &lt;code&gt;/api/run&lt;/code&gt;, and &lt;code&gt;lib/relay.js&lt;/code&gt; holds all the token, policy, and ledger logic in under 400 lines. Biscuit runs as WebAssembly inside the Worker.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delegating authority across companies with Biscuit tokens&lt;/strong&gt; (this post)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attenuating Biscuit tokens and signing the delegation chain&lt;/strong&gt;: minting the root grant, appending restrictions, and why each hop needs a signed envelope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local policy, shared facts: the receiver's Datalog authorizer&lt;/strong&gt;: one policy, four sources of facts, and how each denial scenario fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credits without a mutable token: reservations, idempotency and receipts&lt;/strong&gt;: the clearinghouse, concurrent reservations, retries, and what a production system would still need.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code is at &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;. Issues and PRs are welcome.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authorization</category>
      <category>javascript</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Run It, Deploy It, and What's Honestly Not Done Yet</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:54:57 +0000</pubDate>
      <link>https://dev.to/darkedges/run-it-deploy-it-and-whats-honestly-not-done-yet-32j1</link>
      <guid>https://dev.to/darkedges/run-it-deploy-it-and-whats-honestly-not-done-yet-32j1</guid>
      <description>&lt;p&gt;Repo: &lt;a href="https://github.com/darkedges/pingfederate-graph-broker" rel="noopener noreferrer"&gt;darkedges/pingfederate-graph-broker&lt;/a&gt;*&lt;/p&gt;

&lt;p&gt;We've covered the problem, the PingFederate courier flow, introspection and delegations and the token vault. Here is how to run it and where it stands.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The zero-credential demo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.demo.yaml up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;   &lt;span class="c"&gt;# http://127.0.0.1:8097&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.demo.yaml down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local simulators stand in for PingFederate, Entra and Graph, with a temporary encrypted store. State resets on restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Tests
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-race&lt;/span&gt; &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 ./...
go vet ./...
go build &lt;span class="nt"&gt;-buildvcs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; ./cmd/broker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go 1.26+ is required. The race detector needs a C compiler, so on Windows use WSL2 or Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A live local stack
&lt;/h2&gt;

&lt;p&gt;Terraform is split into separate states because PingFederate must exist before its provider can connect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;terraform/runtime&lt;/code&gt;: Docker Compose runtime&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform/scopes&lt;/code&gt;: adopts PF's global OAuth scopes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform&lt;/code&gt; (root): Entra app registration, access token managers, clients, IdP connection, Reference ID adapter&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;make compose-pf&lt;/code&gt;, &lt;code&gt;make compose-broker&lt;/code&gt; and &lt;code&gt;make compose-portal&lt;/code&gt; start the pieces. The portal needs a local trusted certificate (for example from mkcert), and PingFederate needs Ping DevOps credentials.&lt;/p&gt;

&lt;p&gt;For public hostnames there's a Cloudflare Tunnel guide. The one rule: &lt;strong&gt;never tunnel the PF admin port&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Kubernetes
&lt;/h2&gt;

&lt;p&gt;A Helm chart in &lt;code&gt;helm/broker&lt;/code&gt; deploys the broker and portal in one pod with a persistent volume. It uses a &lt;code&gt;Recreate&lt;/code&gt; strategy and rejects replicas other than 1, because of the file store. Secrets come from an &lt;code&gt;existingSecret&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 helm-lint
make helm-template
make helm-upgrade &lt;span class="nv"&gt;HELM_VALUES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The repo's verification log marks live PingFederate, Entra and Graph integration as &lt;strong&gt;not run&lt;/strong&gt;. Mock-based tests pass; a real tenant acceptance checklist (11 steps in &lt;code&gt;docs/OPERATIONS.md&lt;/code&gt;) is yet to be completed. Also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single instance only, no distributed storage or locking&lt;/li&gt;
&lt;li&gt;No application-level rate limiting&lt;/li&gt;
&lt;li&gt;The optional SAML on-behalf-of path (PF token exchange to SAML 1.1 to Entra OBO to Graph) is documented but not provisioned or proven&lt;/li&gt;
&lt;li&gt;Public Microsoft cloud only, single tenant&lt;/li&gt;
&lt;li&gt;Terraform doesn't yet cover the full PF handoff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd rather say this up front than have you find out in a test environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roadmap
&lt;/h2&gt;

&lt;p&gt;The next production milestone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL transactions with per-connection advisory locks&lt;/li&gt;
&lt;li&gt;Managed key encryption (KMS) and key rotation&lt;/li&gt;
&lt;li&gt;Per-object authorisation policy&lt;/li&gt;
&lt;li&gt;Metrics and rate limiting&lt;/li&gt;
&lt;li&gt;Integration tests against a non-production PF/Entra environment&lt;/li&gt;
&lt;li&gt;Possibly an MCP transport so agent frameworks can consume the directory tools directly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get involved
&lt;/h2&gt;

&lt;p&gt;If you work on PingFederate, Entra or agent security, I'd love feedback, especially on the &lt;code&gt;broker_principal_type&lt;/code&gt; contract and the delegation model. Open an issue at &lt;a href="https://github.com/darkedges/pingfederate-graph-broker" rel="noopener noreferrer"&gt;github.com/darkedges/pingfederate-graph-broker&lt;/a&gt;.&lt;/p&gt;

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

</description>
      <category>pingfederate</category>
      <category>go</category>
      <category>terraform</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Securing the Token Vault: Encrypted State, Refresh Rotation and Safe Logs</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:54:34 +0000</pubDate>
      <link>https://dev.to/darkedges/securing-the-token-vault-encrypted-state-refresh-rotation-and-safe-logs-15oh</link>
      <guid>https://dev.to/darkedges/securing-the-token-vault-encrypted-state-refresh-rotation-and-safe-logs-15oh</guid>
      <description>&lt;p&gt;Repo: &lt;a href="https://github.com/darkedges/pingfederate-graph-broker" rel="noopener noreferrer"&gt;darkedges/pingfederate-graph-broker&lt;/a&gt;*&lt;/p&gt;

&lt;p&gt;A broker that holds refresh tokens is a high-value target. This part walks through the controls that protect them, and the trade-offs the starter deliberately accepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  One encrypted file
&lt;/h2&gt;

&lt;p&gt;All state lives in a single file, &lt;code&gt;DATA_DIR/state.enc&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AES-256-GCM&lt;/strong&gt; with random nonces and version-specific additional authenticated data&lt;/li&gt;
&lt;li&gt;Key supplied as &lt;code&gt;TOKEN_ENCRYPTION_KEY&lt;/code&gt; (for example from &lt;code&gt;openssl rand -base64 32&lt;/code&gt;). Lose it and the store is unreadable&lt;/li&gt;
&lt;li&gt;Writes go to a temp file, &lt;code&gt;fsync&lt;/code&gt;, then an &lt;strong&gt;atomic rename&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If the directory &lt;code&gt;fsync&lt;/code&gt; fails, the store is &lt;strong&gt;poisoned until restart&lt;/strong&gt;, rather than continuing in an uncertain state&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;flock&lt;/code&gt; prevents a second process from opening the same store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off: the whole file is re-encrypted on every mutation, and &lt;code&gt;flock&lt;/code&gt; means no native Windows storage. That's fine for a single instance and wrong for a fleet, which is why Postgres is the next milestone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh rotation without races
&lt;/h2&gt;

&lt;p&gt;Entra can rotate refresh tokens, so a naive implementation can lose the only valid one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tokens refresh when &lt;strong&gt;less than 90 seconds&lt;/strong&gt; remain&lt;/li&gt;
&lt;li&gt;Refreshes are &lt;strong&gt;serialised per connection&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A new refresh token replaces the old one. If the response omits one, the existing one is preserved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation takes the same connection lock as refresh&lt;/strong&gt;, so an in-flight refresh can't resurrect a token the user just deleted&lt;/li&gt;
&lt;li&gt;Store callbacks make &lt;strong&gt;no network calls&lt;/strong&gt;, so a slow upstream can't hold the store lock&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Logs that can't leak
&lt;/h2&gt;

&lt;p&gt;Audit logging records only the client ID, operation, outcome and delegation ID. It never contains tokens, request bodies, reference IDs, query strings or raw upstream errors.&lt;/p&gt;

&lt;p&gt;Even debug logging is restricted to fixed categories. For example, a malformed pickup is reported as &lt;code&gt;format_shape=string_non_json&lt;/code&gt; instead of echoing the value. When debugging an identity flow, it's tempting to log the payload. This design makes that impossible by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The portal's browser-facing defences
&lt;/h2&gt;

&lt;p&gt;The portal is a Go HTTPS backend with an embedded Next.js UI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The PF token stays server-side. The browser gets an &lt;strong&gt;opaque Secure, HttpOnly cookie&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;CSRF and &lt;code&gt;Origin&lt;/code&gt; checks on state-changing requests&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TargetResource&lt;/code&gt; is matched exactly on the Reference ID callback&lt;/li&gt;
&lt;li&gt;The Form POST from PF ends on a &lt;strong&gt;same-origin continuation page&lt;/strong&gt;. A cross-origin POST redirect would otherwise run into the CSP &lt;code&gt;form-action 'self'&lt;/code&gt; rule&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tests that target the dangerous paths
&lt;/h2&gt;

&lt;p&gt;The broker test suite includes a full mocked lifecycle (&lt;code&gt;TestCompleteLifecycle&lt;/code&gt;) and cases for replay, scope escalation, tamper detection, concurrency and credential-bearing redirects. The project runs &lt;code&gt;go test -race&lt;/code&gt;, &lt;code&gt;go vet&lt;/code&gt; and a build in GitHub Actions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-race&lt;/span&gt; &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ./internal/broker &lt;span class="nt"&gt;-run&lt;/span&gt; &lt;span class="s1"&gt;'^TestCompleteLifecycle$'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Known limits
&lt;/h2&gt;

&lt;p&gt;This is not a hardened product. Still open: managed key encryption, key rotation, per-object authorisation policy, rate limiting, and protection against rollback by a privileged filesystem operator.&lt;/p&gt;

&lt;p&gt;Last part: running it, deploying it and what comes next.&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>encryption</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Introspection and Delegations: Authorising Agents Per User, Per Operation</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:54:14 +0000</pubDate>
      <link>https://dev.to/darkedges/introspection-and-delegations-authorising-agents-per-user-per-operation-4m14</link>
      <guid>https://dev.to/darkedges/introspection-and-delegations-authorising-agents-per-user-per-operation-4m14</guid>
      <description>&lt;p&gt;Repo: &lt;a href="https://github.com/darkedges/pingfederate-graph-broker" rel="noopener noreferrer"&gt;darkedges/pingfederate-graph-broker&lt;/a&gt;*&lt;/p&gt;

&lt;p&gt;Once a user has linked their Entra account, how does an agent get to use it? Two things must both be true on every request: the caller holds a valid PingFederate token of the right kind, and a user-created &lt;strong&gt;delegation&lt;/strong&gt; covers the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introspect every request
&lt;/h2&gt;

&lt;p&gt;The broker calls PF's &lt;code&gt;/as/introspect.oauth2&lt;/code&gt; for &lt;strong&gt;every&lt;/strong&gt; protected request. There is no caching of authorisation results, so a revoked or expired token stops working immediately.&lt;/p&gt;

&lt;p&gt;An accepted response 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;"active"&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="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;"Bearer"&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://pf.example.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;"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://directory-broker.example.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;"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;2000000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"client_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"directory-portal"&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;"canonical-user-id"&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;"broker.connect"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"broker_principal_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;"user"&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 broker requires an exact &lt;code&gt;iss&lt;/code&gt;, an &lt;code&gt;aud&lt;/code&gt; equal to its configured audience, &lt;code&gt;token_type&lt;/code&gt; of Bearer, plus &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;scope&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two callers, two contracts
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller&lt;/th&gt;
&lt;th&gt;PF scope&lt;/th&gt;
&lt;th&gt;Fixed claim&lt;/th&gt;
&lt;th&gt;Allowed client IDs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Portal acting for a user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;broker.connect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PF_PORTAL_CLIENT_IDS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;broker.directory.read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PF_AGENT_CLIENT_IDS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;broker_principal_type&lt;/code&gt; is &lt;strong&gt;not a built-in PingFederate claim&lt;/strong&gt;. It's a contract this broker introduces, set to a fixed value per client and flow in the access token manager. That prevents an agent token from ever being mistaken for a user token, and vice versa.&lt;/p&gt;

&lt;p&gt;The portal uses the authorization code flow with PKCE. Agents use client credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delegations
&lt;/h2&gt;

&lt;p&gt;A user (via the portal) grants an agent a delegation on one of their connections:&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="err"&gt;POST /v1/connections/{connection}/delegations
Authorization: Bearer &amp;lt;user token&amp;gt;

{
  "agent_client_id": "directory-agent",
  "operations": ["directory.find_users", "directory.find_groups", "directory.list_group_members"],
  "expires_in_seconds": 86400
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A delegation binds &lt;strong&gt;owner, connection, agent client, operations and expiry&lt;/strong&gt;. Lifetime is between 60 seconds and 7 days. It is re-validated on every request, and the owner can revoke it with &lt;code&gt;DELETE /v1/delegations/{delegation}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent's view
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/v1/delegations/{delegation}/users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;directory.find_users&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/v1/delegations/{delegation}/groups&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;directory.find_groups&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/v1/delegations/{delegation}/groups/{group}/members&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;directory.list_group_members&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PF_AGENT_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://directory-broker.example.com/v1/delegations/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DELEGATION_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/users?prefix=Nick"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent never names a user or a connection. The delegation ID resolves to both, and only if the agent's PF &lt;code&gt;client_id&lt;/code&gt; matches the one in the delegation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graph calls are narrow by construction
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Graph v1.0 &lt;code&gt;GET&lt;/code&gt; requests only&lt;/li&gt;
&lt;li&gt;Fixed page size of 25&lt;/li&gt;
&lt;li&gt;Raw &lt;code&gt;$filter&lt;/code&gt;, &lt;code&gt;$select&lt;/code&gt; and URLs from callers are rejected&lt;/li&gt;
&lt;li&gt;Paging cursors are opaque, encrypted and authenticated, expire after 15 minutes and are bound to the delegation and route&lt;/li&gt;
&lt;li&gt;Outbound redirects are disabled, so credentials can't follow a redirect&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failure semantics
&lt;/h2&gt;

&lt;p&gt;The broker distinguishes "the grant is gone" from "something is down":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;invalid_grant&lt;/code&gt;, interaction required, scope escalation or a Graph 401 mark the connection &lt;code&gt;reconnect_required&lt;/code&gt; and clear its tokens.&lt;/li&gt;
&lt;li&gt;Outages and client-credential errors do &lt;strong&gt;not&lt;/strong&gt; erase a valid grant.&lt;/li&gt;
&lt;li&gt;A Graph 403 does not trigger endless refresh attempts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Silent connections with SAML Bearer Token Exchange
&lt;/h2&gt;

&lt;p&gt;The browser link flow in part 2 needs the user to go through an Entra login. For cases where that interaction isn't wanted, the broker has an optional path (&lt;code&gt;SAML_ENABLED=true&lt;/code&gt;, &lt;code&gt;POST /v1/connections/saml&lt;/code&gt;) built on PingFederate's token exchange:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The portal supplies a PF reference token for the signed-in user.&lt;/li&gt;
&lt;li&gt;PF's &lt;strong&gt;SAML Bearer Token Exchange&lt;/strong&gt; issues a SAML 1.1 assertion for that user.&lt;/li&gt;
&lt;li&gt;The assertion is exchanged at Entra for a token for a custom API.&lt;/li&gt;
&lt;li&gt;Entra's &lt;strong&gt;on-behalf-of&lt;/strong&gt; flow turns that into a Microsoft Graph token.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's no browser redirect and no consent prompt per connection. The trade-off is that the resulting connection is &lt;strong&gt;session-limited&lt;/strong&gt;: there is no refresh token to store, so it lasts as long as the Graph token does.&lt;/p&gt;

&lt;p&gt;Two honest caveats. This path is not yet provisioned or proven against a live PF/Entra environment. And in the existing environment inspected, the Graph token for the demonstrated app carried a write permission, which must not be used unchanged: the broker's read-only rule applies to this path too.&lt;/p&gt;

&lt;p&gt;Next: how the broker protects the tokens it holds.&lt;/p&gt;

</description>
      <category>pingfederate</category>
      <category>oauth</category>
      <category>api</category>
      <category>security</category>
    </item>
    <item>
      <title>PingFederate as a Token Courier: Linking Entra with the Reference ID Adapter</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:53:46 +0000</pubDate>
      <link>https://dev.to/darkedges/pingfederate-as-a-token-courier-linking-entra-with-the-reference-id-adapter-7do</link>
      <guid>https://dev.to/darkedges/pingfederate-as-a-token-courier-linking-entra-with-the-reference-id-adapter-7do</guid>
      <description>&lt;p&gt;Repo: &lt;a href="https://github.com/darkedges/pingfederate-graph-broker" rel="noopener noreferrer"&gt;darkedges/pingfederate-graph-broker&lt;/a&gt;*&lt;/p&gt;

&lt;p&gt;In part 1 we said Entra tokens stay on the backend. But someone has to obtain them. Here the broker uses PingFederate as the courier: PF runs the upstream OIDC login to Entra, and the broker picks up the result server-to-server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moving parts in PingFederate
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;upstream Entra OIDC IdP connection&lt;/strong&gt; (authorization code with PKCE) requesting &lt;code&gt;offline_access&lt;/code&gt; plus the two Graph read scopes.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Reference ID SP Adapter&lt;/strong&gt; from the Agentless Integration Kit. It hands the portal an opaque reference instead of the attributes themselves.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;pickup endpoint&lt;/strong&gt; the broker calls with HTTP Basic auth to exchange that reference for attributes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The attributes in the pickup contract:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&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;subject&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Canonical PF user ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;entra_tid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Entra tenant ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;entra_oid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Entra object ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;entra_token_response&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Token endpoint response (Context → Token Endpoint Response)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The connection flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The user signs in to the portal through PF (authorization code + PKCE). The PF token stays &lt;strong&gt;server-side&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The portal calls &lt;code&gt;POST /v1/link-intents&lt;/code&gt;. The broker creates a &lt;strong&gt;one-use, five-minute&lt;/strong&gt; intent.&lt;/li&gt;
&lt;li&gt;The browser goes through the PF SP journey, which logs in to Entra upstream.&lt;/li&gt;
&lt;li&gt;The Reference ID adapter Form POSTs &lt;code&gt;REF&lt;/code&gt; and &lt;code&gt;TargetResource&lt;/code&gt; to the portal callback.&lt;/li&gt;
&lt;li&gt;The portal calls &lt;code&gt;POST /v1/link-intents/{id}/complete&lt;/code&gt; with &lt;code&gt;{"reference":"&amp;lt;REF&amp;gt;"}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The broker performs the authenticated pickup from PF.&lt;/li&gt;
&lt;li&gt;The broker checks that &lt;code&gt;subject&lt;/code&gt; equals the &lt;code&gt;sub&lt;/code&gt; of the portal's token.&lt;/li&gt;
&lt;li&gt;The broker redeems the refresh token immediately to prove it works, then saves the connection encrypted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The browser only ever carries an opaque reference. The Microsoft token response travels PF to broker, never through the portal or the user agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Binding by subject, not by email
&lt;/h2&gt;

&lt;p&gt;The most important check is step 7. A pickup whose &lt;code&gt;subject&lt;/code&gt; differs from the calling user's &lt;code&gt;sub&lt;/code&gt; is rejected. Connections are bound to the authenticated canonical subject and &lt;strong&gt;never&lt;/strong&gt; to an email address or UPN, which are mutable and attacker-influenced in many directories.&lt;/p&gt;

&lt;p&gt;Link intents add more guardrails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one-use and owner-bound&lt;/li&gt;
&lt;li&gt;a new intent cancels the previous one&lt;/li&gt;
&lt;li&gt;completion &lt;strong&gt;consumes the intent before pickup&lt;/strong&gt;, so a failed or replayed attempt can't be retried&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scope allowlisting at import
&lt;/h2&gt;

&lt;p&gt;When a connection is imported (and on every refresh), the broker inspects the granted scopes. Both read scopes must be present. OIDC scopes and &lt;code&gt;User.Read&lt;/code&gt; are tolerated. Anything else, including broader reads or any write scope, is rejected, and a scope escalation marks the connection &lt;code&gt;reconnect_required&lt;/code&gt; with its tokens cleared.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on honesty
&lt;/h2&gt;

&lt;p&gt;The Terraform in the repo provisions most of this, but the PingFederate provider doesn't yet expose an SP token-generator resource, so part of the handoff is manual. The live PF/Entra path is also not verified end to end. Part 5 covers exactly what is and isn't proven.&lt;/p&gt;

&lt;p&gt;Next: how the agent side works, with PingFederate introspection and the delegation model.&lt;/p&gt;

</description>
      <category>pingfederate</category>
      <category>oauth</category>
      <category>microsoft</category>
      <category>security</category>
    </item>
  </channel>
</rss>
