<?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>Trusted AI Agent Transactions, Part 5: End-to-End Proof</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 23 Aug 2026 23:03:55 +0000</pubDate>
      <link>https://dev.to/darkedges/trusted-ai-agent-transactions-part-5-end-to-end-proof-44b5</link>
      <guid>https://dev.to/darkedges/trusted-ai-agent-transactions-part-5-end-to-end-proof-44b5</guid>
      <description>&lt;h2&gt;
  
  
  Building and proving the complete request path
&lt;/h2&gt;

&lt;p&gt;The previous articles covered the identity model, &lt;a href="//02-pingfederate-token-exchange.md"&gt;PingFederate token exchange&lt;/a&gt;, &lt;a href="//03-spire-workload-identity.md"&gt;SPIRE workload identity&lt;/a&gt;, and &lt;a href="//04-pingauthorize-policy.md"&gt;PingAuthorize policy&lt;/a&gt;. This final part connects them into one testable application path.&lt;/p&gt;

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

&lt;p&gt;The browser workbench signs the user in through PingFederate with OAuth 2.0 Authorization Code and PKCE. The application never collects the user's PingFederate password.&lt;/p&gt;

&lt;p&gt;After sign-in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the demo agent receives the user access token&lt;/li&gt;
&lt;li&gt;the agent obtains a JWT-SVID from SPIRE&lt;/li&gt;
&lt;li&gt;PingFederate, acting as the TTS, exchanges the subject and actor evidence for a short-lived Txn-Token&lt;/li&gt;
&lt;li&gt;the agent sends that token in the &lt;code&gt;Txn-Token&lt;/code&gt; header while calling the MCP gateway over SPIFFE mTLS&lt;/li&gt;
&lt;li&gt;the gateway validates the transaction token and immediate caller&lt;/li&gt;
&lt;li&gt;OPA or PingAuthorize evaluates the verified target and tool request&lt;/li&gt;
&lt;li&gt;the gateway forwards the unchanged transaction token to the MCP server over mTLS&lt;/li&gt;
&lt;li&gt;the MCP server calls the protected API over another authenticated mTLS hop&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The original user access token does not travel beyond the exchange. The Txn-Token does not become a browser session token, does not use the &lt;code&gt;Authorization&lt;/code&gt; header for internal propagation, and is not rewritten by intermediaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  A UI that shows evidence without exposing credentials
&lt;/h2&gt;

&lt;p&gt;The workbench uses two-thirds of the screen for service interaction and one-third for a selectable audit trail. Events correlate the same transaction ID across the agent, gateway, MCP server, API, and authorization decision.&lt;/p&gt;

&lt;p&gt;Selecting an event can show safe request and response metadata plus decoded, allowlisted token claims. It must not expose raw access tokens, JWT-SVIDs, transaction JWTs, authorization headers, cookies, authorization codes, client secrets, refresh tokens, private keys, or sensitive tool arguments.&lt;/p&gt;

&lt;p&gt;Decoded claims are evidence for a human operator, not a new authorization input. Services authorize only after cryptographic validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository-owned product startup
&lt;/h2&gt;

&lt;p&gt;Both Ping products use digest-pinned images and read-only repository-owned profile overlays.&lt;/p&gt;

&lt;p&gt;PingFederate startup builds and tests the custom token processor plugin. Its Terraform configuration remains a separate explicit step.&lt;/p&gt;

&lt;p&gt;PingAuthorize starts with a repository-owned deployment package selected during first setup. The package is mounted read-only, and the container joins only the expected local application bridge network.&lt;/p&gt;

&lt;p&gt;Secrets, licenses, generated certificates, private keys, Terraform state, and discovery output remain outside Git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the security statement
&lt;/h2&gt;

&lt;p&gt;The main authorization statement is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verified transaction context
AND verified immediate caller
AND target policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The end-to-end suite therefore needs more than a success case. It proves rejection for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;forged logical agent identity&lt;/li&gt;
&lt;li&gt;wrong SPIFFE workload&lt;/li&gt;
&lt;li&gt;expired transaction token&lt;/li&gt;
&lt;li&gt;wrong audience&lt;/li&gt;
&lt;li&gt;unapproved MCP target&lt;/li&gt;
&lt;li&gt;stolen transaction token presented by the wrong mTLS caller&lt;/li&gt;
&lt;li&gt;direct agent-to-API access when only the MCP server is allowed&lt;/li&gt;
&lt;li&gt;malformed or contradictory authorization decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also proves that one transaction ID appears across all expected hops and that captured logs contain no raw token material.&lt;/p&gt;

&lt;p&gt;The PingFederate clean-bootstrap test goes further by recreating the product on an isolated volume and Terraform state, applying managed TLS, completing a live exchange, rejecting a tampered actor token, and cleaning only its randomly named resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Agent security is not solved by adding an &lt;code&gt;agent_id&lt;/code&gt; claim or passing a user token farther downstream. The implementation needs distinct evidence for the user, logical agent, workload, transaction, and immediate caller.&lt;/p&gt;

&lt;p&gt;PingFederate provides the controlled delegation boundary. SPIRE proves runtime identity. PingAuthorize evaluates the verified action context. The gateway preserves the boundary between identity verification, routing, and policy enforcement.&lt;/p&gt;

&lt;p&gt;Together, they make an agent action explainable and testable: who authorized it, which agent was approved, which workload ran it, why the transaction exists, which service made each call, and which policy allowed or denied the operation.&lt;/p&gt;

&lt;p&gt;Previous: &lt;a href="//04-pingauthorize-policy.md"&gt;Making policy decisions with PingAuthorize&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Return to the &lt;a href="//README.md"&gt;series index&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;GitHub Repository: &lt;a href="https://github.com/darkedges/pf-tts" rel="noopener noreferrer"&gt;https://github.com/darkedges/pf-tts&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>pingidentity</category>
      <category>mcp</category>
      <category>security</category>
    </item>
    <item>
      <title>Trusted AI Agent Transactions, Part 4: PingAuthorize Policy Decisions</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 23 Aug 2026 23:03:22 +0000</pubDate>
      <link>https://dev.to/darkedges/trusted-ai-agent-transactions-part-4-pingauthorize-policy-decisions-3ik8</link>
      <guid>https://dev.to/darkedges/trusted-ai-agent-transactions-part-4-pingauthorize-policy-decisions-3ik8</guid>
      <description>&lt;h2&gt;
  
  
  Making policy decisions with PingAuthorize
&lt;/h2&gt;

&lt;p&gt;&lt;a href="//03-spire-workload-identity.md"&gt;Part 3&lt;/a&gt; established the transaction context and immediate caller identity. Authorization still needs to answer a separate question: may this verified combination invoke this target and tool for this purpose?&lt;/p&gt;

&lt;p&gt;PingAuthorize is integrated as a remote policy decision point at the MCP gateway. It does not verify the transaction JWT or the mTLS connection. Those checks happen before policy evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only verified input crosses the policy boundary
&lt;/h2&gt;

&lt;p&gt;The gateway constructs a typed policy request from values it has already verified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;user identity&lt;/li&gt;
&lt;li&gt;logical agent identity&lt;/li&gt;
&lt;li&gt;agent instance identity&lt;/li&gt;
&lt;li&gt;original workload SPIFFE ID&lt;/li&gt;
&lt;li&gt;immediate caller SPIFFE ID&lt;/li&gt;
&lt;li&gt;transaction ID and purpose&lt;/li&gt;
&lt;li&gt;scopes&lt;/li&gt;
&lt;li&gt;MCP target&lt;/li&gt;
&lt;li&gt;tool name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Browser fields and MCP request fields are not trusted as identity evidence. The target and tool come from the gateway's validated route, not an arbitrary policy document supplied by the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  A remote &lt;code&gt;PERMIT&lt;/code&gt; is not enough by itself
&lt;/h2&gt;

&lt;p&gt;The adapter calls a fixed HTTPS &lt;code&gt;/governance-engine&lt;/code&gt; endpoint with explicit connection, request, and decision timeouts. It also bounds the response size and requires an exact JSON schema.&lt;/p&gt;

&lt;p&gt;An allow result requires all of the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;policy status is OKAY
AND decision is PERMIT
AND authorised is true
AND no unfulfilled obligatory statement exists
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing, contradictory, malformed, oversized, timed-out, cancelled, or unavailable responses deny. Unknown JSON fields deny. A &lt;code&gt;PERMIT&lt;/code&gt; paired with &lt;code&gt;authorised: false&lt;/code&gt; denies. Obligations deny until a named handler is implemented and tested.&lt;/p&gt;

&lt;p&gt;TLS verification cannot be disabled, and the PingAuthorize hostname must match its runtime certificate. The policy package is mounted read-only and cannot be loaded from an arbitrary network location.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy tuple
&lt;/h2&gt;

&lt;p&gt;The repository-owned deployment package permits only an exact conjunction over the trusted values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;logical agent
AND original workload
AND immediate caller
AND purpose
AND scope
AND target
AND tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters for AI agent integrations because a broad statement such as "this agent may use MCP" is usually too weak. The useful decision is closer to "this approved agent workload, acting for this verified user transaction and purpose, may invoke this tool through this caller path."&lt;/p&gt;

&lt;h2&gt;
  
  
  OPA and PingAuthorize can share a contract
&lt;/h2&gt;

&lt;p&gt;The Go gateway has a small authorization interface. An in-process OPA adapter is the default local option, while PingAuthorize implements the same typed contract through its JSON PDP API.&lt;/p&gt;

&lt;p&gt;That makes the policy engine replaceable without moving identity validation into the policy layer. It also allows the same allow and deny matrix to be tested against both implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests prove
&lt;/h2&gt;

&lt;p&gt;Failure coverage includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;forged logical agent&lt;/li&gt;
&lt;li&gt;wrong workload or immediate caller&lt;/li&gt;
&lt;li&gt;missing or ambiguous scope&lt;/li&gt;
&lt;li&gt;malformed and oversized decisions&lt;/li&gt;
&lt;li&gt;policy errors and unavailable service&lt;/li&gt;
&lt;li&gt;unsafe endpoint or disabled TLS validation&lt;/li&gt;
&lt;li&gt;unfulfilled obligations&lt;/li&gt;
&lt;li&gt;cancellation and timeout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The local live test has also shown an exact tuple returning &lt;code&gt;PERMIT&lt;/code&gt; and a forged logical agent returning &lt;code&gt;NOT_APPLICABLE&lt;/code&gt; over certificate-validated HTTPS.&lt;/p&gt;

&lt;p&gt;Previous: &lt;a href="//03-spire-workload-identity.md"&gt;Binding a logical agent to a real workload with SPIRE&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next: &lt;a href="//05-end-to-end-implementation.md"&gt;Building and proving the complete request path&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub Repository: &lt;a href="https://github.com/darkedges/pf-tts" rel="noopener noreferrer"&gt;https://github.com/darkedges/pf-tts&lt;/a&gt;&lt;/p&gt;

</description>
      <category>pingauthorize</category>
      <category>security</category>
      <category>authorization</category>
      <category>ai</category>
    </item>
    <item>
      <title>Trusted AI Agent Transactions, Part 3: SPIRE Workload Identity</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 23 Aug 2026 23:02:55 +0000</pubDate>
      <link>https://dev.to/darkedges/trusted-ai-agent-transactions-part-3-spire-workload-identity-1gfb</link>
      <guid>https://dev.to/darkedges/trusted-ai-agent-transactions-part-3-spire-workload-identity-1gfb</guid>
      <description>&lt;h1&gt;
  
  
  Binding a logical agent to a real workload with SPIRE
&lt;/h1&gt;

&lt;p&gt;&lt;a href="//02-pingfederate-token-exchange.md"&gt;Part 2&lt;/a&gt; used a SPIRE JWT-SVID as the actor token in PingFederate token exchange. That proves more than a caller-supplied agent name, but it helps to understand exactly what it proves.&lt;/p&gt;

&lt;p&gt;A logical agent describes an approved application role. A SPIFFE ID identifies an attested running workload. They are related, but they are not interchangeable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AgentID:  urn:agent:customer-support
SPIFFEID: spiffe://example.org/agent/customer-support
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is a policy identity. The second is a cryptographically attested runtime identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two SVID formats, two jobs
&lt;/h2&gt;

&lt;p&gt;The implementation uses both SPIRE credential formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT-SVID authenticates the agent to PingFederate during RFC 8693 token exchange&lt;/li&gt;
&lt;li&gt;X.509-SVID authenticates workload-to-workload connections with mTLS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The JWT-SVID uses an audience dedicated to the PingFederate Transaction Token Service integration. The Txn-Token audience identifies the trust domain in which the call chain is valid. The protected target and tool belong in transaction context and authorization policy. Reusing the actor-token audience for the Txn-Token would blur two distinct trust boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Immediate caller identity changes at every hop
&lt;/h2&gt;

&lt;p&gt;The transaction token remains unchanged through the request path, but the immediate caller changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent -&amp;gt; Gateway:     caller is the agent SPIFFE ID
Gateway -&amp;gt; MCP:       caller is the gateway SPIFFE ID
MCP -&amp;gt; Protected API: caller is the MCP server SPIFFE ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets the protected API reject a direct agent call even if the agent possesses a valid transaction token. The API can require the verified transaction context and the expected MCP server identity on the current mTLS connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local development without making lab shortcuts production policy
&lt;/h2&gt;

&lt;p&gt;The Docker lab runs a SPIRE Server and Agent and uses Docker workload attestation. Each workload has a distinct label and registration entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wai.workload=demo-agent
wai.workload=mcp-gateway
wai.workload=demo-mcp-server
wai.workload=demo-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those selectors produce separate SPIFFE IDs. Mounting the Workload API socket does not let a container choose an identity. SPIRE observes the calling workload and matches its external selectors to a registered entry.&lt;/p&gt;

&lt;p&gt;The lab uses a join token to bootstrap the SPIRE Agent. That is explicitly a development choice. A production deployment should use a node attestor appropriate to its environment, such as cloud instance identity, Kubernetes PSAT, X.509 proof of possession, or hardware-backed attestation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation and ambiguity
&lt;/h2&gt;

&lt;p&gt;SPIRE signing keys can overlap during normal rotation. The integration accepts multiple JWT authorities only when every entry is explicitly a JWT-SVID authority, uses a unique non-empty key ID, and satisfies the configured key and algorithm constraints.&lt;/p&gt;

&lt;p&gt;Unknown key IDs, duplicate IDs, unexpected algorithms, ambiguous identities, missing audiences, and conflicting claims fail closed. The token header is never allowed to choose a newly trusted algorithm dynamically.&lt;/p&gt;

&lt;p&gt;The binding rule remains the same throughout: a workload cannot become another logical agent by changing request data.&lt;/p&gt;

&lt;p&gt;Previous: &lt;a href="//02-pingfederate-token-exchange.md"&gt;Using PingFederate token exchange as the delegation boundary&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next: &lt;a href="//04-pingauthorize-policy.md"&gt;Making policy decisions with PingAuthorize&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub Repository: &lt;a href="https://github.com/darkedges/pf-tts" rel="noopener noreferrer"&gt;https://github.com/darkedges/pf-tts&lt;/a&gt;&lt;/p&gt;

</description>
      <category>spiffe</category>
      <category>spire</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>Trusted AI Agent Transactions, Part 2: PingFederate Token Exchange</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 23 Aug 2026 23:01:49 +0000</pubDate>
      <link>https://dev.to/darkedges/trusted-ai-agent-transactions-part-2-pingfederate-token-exchange-35pn</link>
      <guid>https://dev.to/darkedges/trusted-ai-agent-transactions-part-2-pingfederate-token-exchange-35pn</guid>
      <description>&lt;h2&gt;
  
  
  Using PingFederate as a Transaction Token Service
&lt;/h2&gt;

&lt;p&gt;&lt;a href="//01-the-identity-problem.md"&gt;Part 1&lt;/a&gt; separated the user, logical agent, runtime workload, agent execution, and transaction identities. This part shows where they are joined safely.&lt;/p&gt;

&lt;p&gt;In the Tokenetes architecture, a Transaction Token Service, or TTS, uses the RFC 8693 token exchange protocol to mint a Txn-Token. This implementation assigns that logical role to PingFederate.&lt;/p&gt;

&lt;p&gt;The agent sends:&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      = the user's access token
subject_token_type = access token
actor_token        = the agent workload's JWT-SVID
actor_token_type   = JWT
requested_token_type = urn:ietf:params:oauth:token-type:txn_token
audience           = the transaction trust domain
scope              = the narrow purpose of this transaction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subject token answers who authorized the external invocation. The SPIRE actor token is an agent-specific extension that proves which attested workload is requesting the exchange. Neither token is accepted as a substitute for the other. The base Transaction Tokens profile relies on authenticated TTS clients; this design additionally carries explicit actor evidence so PingFederate can bind a logical AI agent to its runtime workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example subject and actor tokens
&lt;/h2&gt;

&lt;p&gt;The following payloads are illustrative decoded claims. In the implementation, claims are used only after the complete token has passed signature, issuer, audience, algorithm, key ID, and time validation. Raw tokens are never written to application or audit logs.&lt;/p&gt;

&lt;p&gt;The subject token is the user's OAuth access token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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://pingfederate.example"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user-123"&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;"wai-agent-token-exchange"&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;"openid profile mcp:invoke"&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;"wai-browser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1770000000&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;1770000300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subject-token-id"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It represents the authenticated user. It does not prove which agent workload is presenting it, and it is not forwarded to the MCP gateway or downstream services.&lt;/p&gt;

&lt;p&gt;The actor token is a JWT-SVID issued by SPIRE to the running agent workload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://spire.example.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"spiffe://example.org/agent/demo"&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="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"urn:pingfederate:wai:token-exchange"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1770000000&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;1770000060&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It proves the SPIFFE workload identity to PingFederate. It does not identify the user, and it does not let the workload choose a logical &lt;code&gt;AgentID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The agent submits both tokens to PingFederate using a form-encoded RFC 8693 request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/as/token.oauth2&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pingfederate.example&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/x-www-form-urlencoded&lt;/span&gt;

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&amp;amp;subject_token=REDACTED_USER_ACCESS_TOKEN
&amp;amp;subject_token_type=urn:ietf:params:oauth:token-type:access_token
&amp;amp;actor_token=REDACTED_SPIRE_JWT_SVID
&amp;amp;actor_token_type=urn:ietf:params:oauth:token-type:jwt
&amp;amp;requested_token_type=urn:ietf:params:oauth:token-type:txn_token
&amp;amp;audience=example.org
&amp;amp;scope=mcp:invoke
&amp;amp;request_details=%7B%22target%22%3A%22mcp-gateway%22%2C%22tool%22%3A%22customer.read%22%7D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The values are redacted here intentionally. Tokens belong in the protected request body over validated TLS, never in a URL, log message, audit event, or error response.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PingFederate validates
&lt;/h2&gt;

&lt;p&gt;The PingFederate Token Exchange Processor Policy validates both sides of the delegation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user token processor validates the subject token&lt;/li&gt;
&lt;li&gt;the SPIRE processor validates the JWT-SVID signature, issuer, audience, time, key ID, and algorithm&lt;/li&gt;
&lt;li&gt;trusted configuration maps the verified SPIFFE ID to a logical &lt;code&gt;AgentID&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the policy issues a small, short-lived transaction JWT for the requested audience&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The caller never supplies an authoritative logical agent identity. A workload such as &lt;code&gt;spiffe://example.org/agent/demo&lt;/code&gt; is mapped to a configured logical agent such as &lt;code&gt;urn:agent:demo&lt;/code&gt; inside the trusted policy boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result is a Txn-Token
&lt;/h2&gt;

&lt;p&gt;After validating the subject and actor independently, PingFederate should issue a short-lived signed JWT conforming to the Transaction Tokens profile. Its protected header identifies it as a Txn-Token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"txntoken+jwt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pingfederate-transaction-signing-key"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The body carries the immutable transaction context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://pingfederate.example/transaction-token-service"&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;"user-123"&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;"example.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"mcp:invoke"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"txn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"019..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"req_wl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"spiffe://example.org/agent/demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tctx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp-gateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer.read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"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;"urn:agent:demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instance_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;"019..."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"workload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"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;"spiffe://example.org/agent/demo"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rctx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"authn"&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:ietf:rfc:6749"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;0&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 response uses the normal OAuth token response shape:&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;"REDACTED_TRANSACTION_JWT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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:ietf:params:oauth:token-type:txn_token"&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="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 Txn-Token is not sent in the OAuth &lt;code&gt;Authorization&lt;/code&gt; header between internal workloads. It uses its dedicated header:&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;Txn-Token: REDACTED_TRANSACTION_JWT
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is not replay resistant, so TLS, its very short lifetime, trust-domain audience validation, and immediate-caller SPIFFE mTLS are all required. The same Txn-Token remains unchanged through the MCP gateway, MCP server, and protected API. Each service validates it before using any decoded claim.&lt;/p&gt;

&lt;p&gt;Arbitrary claims are not copied from either input token. The original user access token and actor JWT-SVID stop at the exchange boundary and are not propagated to MCP servers or downstream APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current implementation status
&lt;/h2&gt;

&lt;p&gt;The repository already implements the difficult security boundaries: RFC 8693 exchange, independent subject and actor validation, workload-to-agent binding, short lifetime, immutable propagation, strict issuer and audience checks, SPIFFE mTLS at each hop, target authorization, and correlated audit.&lt;/p&gt;

&lt;p&gt;It currently represents the result with application-specific transaction claims and bearer-token transport. To claim Tokenetes-style Transaction Tokens conformance, it still needs to emit &lt;code&gt;typ: txntoken+jwt&lt;/code&gt;, request and return the Transaction Token type URN, map the context to &lt;code&gt;txn&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;tctx&lt;/code&gt;, &lt;code&gt;rctx&lt;/code&gt;, and &lt;code&gt;req_wl&lt;/code&gt;, use the trust domain as &lt;code&gt;aud&lt;/code&gt;, and propagate the JWT in the &lt;code&gt;Txn-Token&lt;/code&gt; header. Validation must reject the older application token shape once that migration is enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Terraform and plugin discovery
&lt;/h2&gt;

&lt;p&gt;The reference implementation manages PingFederate with the official Terraform provider. Terraform owns the token processors, token exchange policy, access token manager, mapping, scope, and OAuth client.&lt;/p&gt;

&lt;p&gt;Plugin field names are discovered from the running PingFederate version before configuration is generated. This avoids guessing descriptor fields or weakening validation when product versions differ.&lt;/p&gt;

&lt;p&gt;Startup and configuration are separate trust operations. The profile starts the pinned product and installs the tested custom plugin. Scope creation and Terraform apply remain explicit operations, so starting a container cannot silently rotate an OAuth client secret or mutate token policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean bootstrap without committed secrets
&lt;/h2&gt;

&lt;p&gt;The repository-owned profile overlay is mounted read-only. A local generator creates short-lived bootstrap TLS material, datastore credentials, and system keys with cryptographically secure randomness. Those values are written to an ignored local file and never committed.&lt;/p&gt;

&lt;p&gt;The clean-volume test then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;starts PingFederate with random test-owned names and ports&lt;/li&gt;
&lt;li&gt;validates the bootstrap certificate&lt;/li&gt;
&lt;li&gt;applies managed TLS first&lt;/li&gt;
&lt;li&gt;reconnects using the new trust anchor&lt;/li&gt;
&lt;li&gt;applies the complete Terraform configuration&lt;/li&gt;
&lt;li&gt;performs a live token exchange&lt;/li&gt;
&lt;li&gt;proves that a tampered actor token is rejected&lt;/li&gt;
&lt;li&gt;removes only the exact test-owned resources&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is important because a configuration that only works against a long-lived developer volume is not a reproducible security control.&lt;/p&gt;

&lt;p&gt;Previous: &lt;a href="//01-the-identity-problem.md"&gt;The identity problem behind AI agents&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next: &lt;a href="//03-spire-workload-identity.md"&gt;Binding a logical agent to a real workload with SPIRE&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;References: &lt;a href="https://www.cncf.io/projects/tokenetes/" rel="noopener noreferrer"&gt;CNCF Tokenetes&lt;/a&gt; and the &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/" rel="noopener noreferrer"&gt;IETF Transaction Tokens draft&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;GitHub Repository: &lt;a href="https://github.com/darkedges/pf-tts" rel="noopener noreferrer"&gt;https://github.com/darkedges/pf-tts&lt;/a&gt;&lt;/p&gt;

</description>
      <category>pingfederate</category>
      <category>oauth</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>Trusted AI Agent Transactions, Part 1: The Identity Problem</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Sun, 23 Aug 2026 23:00:58 +0000</pubDate>
      <link>https://dev.to/darkedges/trusted-ai-agent-transactions-part-1-the-identity-problem-17aa</link>
      <guid>https://dev.to/darkedges/trusted-ai-agent-transactions-part-1-the-identity-problem-17aa</guid>
      <description>&lt;h2&gt;
  
  
  The identity problem behind AI agents
&lt;/h2&gt;

&lt;p&gt;An application that calls an API for a user normally answers one identity question: who is the user?&lt;/p&gt;

&lt;p&gt;An AI agent introduces several more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which user authorized the work?&lt;/li&gt;
&lt;li&gt;Which logical agent is acting?&lt;/li&gt;
&lt;li&gt;Which running workload actually made the call?&lt;/li&gt;
&lt;li&gt;Which agent execution is involved?&lt;/li&gt;
&lt;li&gt;Which transaction and purpose does the call belong to?&lt;/li&gt;
&lt;li&gt;Which service made the current network hop?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Putting every answer into a single &lt;code&gt;sub&lt;/code&gt; claim creates a dangerous illusion. A logical agent name is not cryptographic proof of the running process. A workload identity is not the user. A transaction identifier is not a caller identity.&lt;/p&gt;

&lt;p&gt;This implementation keeps five values separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UserID&lt;/code&gt;: the authenticated human or upstream principal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AgentID&lt;/code&gt;: the approved logical agent definition&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AgentInstanceID&lt;/code&gt;: one execution of that agent&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SPIFFEID&lt;/code&gt;: the attested runtime workload&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TransactionID&lt;/code&gt;: the immutable identifier for the delegated action&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a user access token is not enough
&lt;/h2&gt;

&lt;p&gt;Passing the original user token through every service has three problems.&lt;/p&gt;

&lt;p&gt;First, downstream services cannot reliably distinguish the user from the agent acting for the user. Second, a broadly scoped user token may reach systems that never needed it. Third, the token usually says little about the purpose of this specific transaction.&lt;/p&gt;

&lt;p&gt;The safer model is the Transaction Tokens architecture implemented by Tokenetes. An external endpoint exchanges the incoming authorization at a controlled Transaction Token Service boundary. The result is a short-lived signed Txn-Token containing immutable identity and authorization context for one call chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an &lt;code&gt;agent_id&lt;/code&gt; field is not proof
&lt;/h2&gt;

&lt;p&gt;An HTTP request can claim any agent name. If the server authorizes that value directly, one workload can impersonate another by changing a string.&lt;/p&gt;

&lt;p&gt;The logical &lt;code&gt;AgentID&lt;/code&gt; must instead come from trusted policy that maps a cryptographically verified workload identity to an approved agent. The caller can request an operation, but it cannot choose its identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Immutable context and authenticated hops
&lt;/h2&gt;

&lt;p&gt;The Txn-Token carries immutable delegated context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user + logical agent + original workload + transaction + purpose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each network connection independently proves the immediate caller through SPIFFE mTLS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verified transaction context
AND verified immediate caller
AND target policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is not rewritten at every hop. That avoids giving intermediate services the power to remove or forge earlier context. The same transaction ID can be correlated across the agent, gateway, MCP server, and protected API.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User OAuth token
  -&amp;gt; AI agent
  -&amp;gt; PingFederate RFC 8693 token exchange
  -&amp;gt; short-lived transaction JWT
  -&amp;gt; MCP gateway
  -&amp;gt; MCP server
  -&amp;gt; protected API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this design, PingFederate fills the logical Transaction Token Service role. SPIRE supplies a JWT-SVID as agent workload evidence during exchange and X.509-SVIDs for mTLS between workloads. PingAuthorize evaluates the verified Txn-Token context at the gateway.&lt;/p&gt;

&lt;p&gt;Tokenetes is the reference point for the call-chain model: a Txn-Token is minted once for the external invocation, remains immutable as it moves downstream, and is accepted only inside its trust domain. The immediate network caller is still authenticated separately.&lt;/p&gt;

&lt;p&gt;The result is not a new identity system. It is a way to combine existing user, workload, delegation, transport, and policy controls without confusing their responsibilities.&lt;/p&gt;

&lt;p&gt;Next: &lt;a href="//02-pingfederate-token-exchange.md"&gt;Using PingFederate token exchange as the delegation boundary&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub Repository: &lt;a href="https://github.com/darkedges/pf-tts" rel="noopener noreferrer"&gt;https://github.com/darkedges/pf-tts&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>oauth</category>
      <category>ai</category>
      <category>pingidentity</category>
    </item>
    <item>
      <title>My detector caught the attacker and never once stopped it, and reported PASS</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:57:39 +0000</pubDate>
      <link>https://dev.to/darkedges/my-detector-caught-the-attacker-and-never-once-stopped-it-and-reported-pass-1eng</link>
      <guid>https://dev.to/darkedges/my-detector-caught-the-attacker-and-never-once-stopped-it-and-reported-pass-1eng</guid>
      <description>&lt;p&gt;The most consequential bug in this project had been there since the beginning,&lt;br&gt;
survived several full end-to-end runs, and was reported as a &lt;strong&gt;PASS&lt;/strong&gt; every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  slow-and-low detected within 30m (7.3m), never exceeding legit rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is true. The scorer flagged it correctly, well inside the bound. What&lt;br&gt;
the line doesn't say is that the attacker was &lt;strong&gt;served every single request it&lt;br&gt;
ever made&lt;/strong&gt;. Zero non-allow decisions, across the entire scenario. Detected and&lt;br&gt;
never once stopped.&lt;/p&gt;

&lt;p&gt;This is one instance of a pattern that accounts for more real bugs in this&lt;br&gt;
project than every other cause combined: &lt;strong&gt;a component contributes nothing, no&lt;br&gt;
error is raised, and every surrounding number stays plausible.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The scorer computes windows at 1m, 5m and 1h, and publishes each result to a&lt;br&gt;
per-client key in Redis and OPA.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Each&lt;/em&gt; result. To the &lt;em&gt;same&lt;/em&gt; key.&lt;/p&gt;

&lt;p&gt;So the last writer won. And 1-minute windows close most often, so they always&lt;br&gt;
won.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;slow-and-low&lt;/code&gt; issues about two requests a minute. Its 1m windows fall below the&lt;br&gt;
minimum request count and score zero. Its 5m and 1h windows accumulate the miss&lt;br&gt;
ratio that earns a &lt;code&gt;deny&lt;/code&gt;. Every one of those zeroes immediately overwrote the&lt;br&gt;
deny.&lt;/p&gt;

&lt;p&gt;The entire premise of a multi-scale pipeline, that different attacks are visible&lt;br&gt;
at different scales, was silently violated by the publication step. Any detection&lt;br&gt;
that only appeared at a coarser scale was discarded.&lt;/p&gt;

&lt;p&gt;The fix is a roll-up: publish the most severe verdict across window sizes within&lt;br&gt;
the freshness horizon the policy already uses. Afterwards, the same attacker is&lt;br&gt;
denied on 24–31 of its 44 requests.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it survived so long
&lt;/h2&gt;

&lt;p&gt;Because &lt;strong&gt;the report could not express it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Detection latency was computed as the &lt;em&gt;earlier&lt;/em&gt; of two very different facts: the&lt;br&gt;
scorer's first non-allow window, and the gateway's first non-allow decision.&lt;br&gt;
Printed under one heading, &lt;code&gt;detected&lt;/code&gt;, a client that was noticed but never&lt;br&gt;
touched looked identical to one that was noticed and blocked.&lt;/p&gt;

&lt;p&gt;A report that averages over the distinction you are trying to verify cannot&lt;br&gt;
verify it.&lt;/p&gt;

&lt;p&gt;The report now derives two facts from two sources and asserts both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;detected&lt;/strong&gt;: the scorer's first non-allow window, from &lt;code&gt;risk_scores&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;enforced&lt;/strong&gt;: the first request the gateway applied a non-allow tier to, from
the access log, which is the record of what actually happened
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  slow-and-low responded within 30.0m (6.3m; scorer 6.3m, gateway 6.5m)
✓ PASS  slow-and-low ENFORCED — 31/44 requests stopped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Splitting them paid for itself immediately, and not only on the bug it was built&lt;br&gt;
for. Look at the dictionary profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  dictionary responded within 60s (9s; scorer 76s, gateway 9s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Enforced at 9 seconds, detected at 76.&lt;/strong&gt; The gateway's fast path stopped it long&lt;br&gt;
before the windowed scorer produced a single verdict. Enforcement &lt;em&gt;preceding&lt;/em&gt;&lt;br&gt;
detection is not an anomaly: it's the fast path doing its job, and the old&lt;br&gt;
report was structurally incapable of showing it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same shape, five more times
&lt;/h2&gt;

&lt;p&gt;Once I had a name for it, it was everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables with no writer.&lt;/strong&gt; &lt;code&gt;access_events&lt;/code&gt;: the raw log the whole "every decision&lt;br&gt;
is reconstructable" claim rests on, was a schema definition and a materialized&lt;br&gt;
view with no producer. Discovered only when I built a dataset export on top of it&lt;br&gt;
and it came back empty. Then &lt;code&gt;detection_labels&lt;/code&gt;, same thing. Then&lt;br&gt;
&lt;code&gt;client_minute_card&lt;/code&gt;, a materialized view aggregating cardinality per client per&lt;br&gt;
minute, populating on every single event, queried by nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An environment-dependent derivation.&lt;/strong&gt; The label-joining query took its window&lt;br&gt;
length from &lt;code&gt;WINDOW_MS&lt;/code&gt;, which is divided by &lt;code&gt;DEMO_SPEED&lt;/code&gt;. The demo sets that to&lt;br&gt;
8; the export script doesn't set it at all. So the export bucketed events into&lt;br&gt;
60-second windows against 7.5-second data and matched almost nothing: &lt;strong&gt;172 of 197&lt;br&gt;
labels silently vanished&lt;/strong&gt;, and the CSV still looked perfectly well-formed, just&lt;br&gt;
smaller.&lt;/p&gt;

&lt;p&gt;The fix generalises. Derive window lengths &lt;strong&gt;from the data&lt;/strong&gt;, since&lt;br&gt;
&lt;code&gt;client_features&lt;/code&gt; records both ends of every window, and never from the&lt;br&gt;
environment that happens to be reading it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Untyped tests.&lt;/strong&gt; The package builds exclude &lt;code&gt;**/*.test.ts&lt;/code&gt;, correctly, so tests&lt;br&gt;
aren't emitted into &lt;code&gt;dist/&lt;/code&gt;. But that also excluded them from &lt;em&gt;type checking&lt;/em&gt;.&lt;br&gt;
Three times a test fixture went stale after a field was added: the missing&lt;br&gt;
property became &lt;code&gt;undefined&lt;/code&gt;, &lt;code&gt;Number(undefined)&lt;/code&gt; became &lt;code&gt;NaN&lt;/code&gt;, and the failure&lt;br&gt;
surfaced as an apparent detector regression.&lt;/p&gt;

&lt;p&gt;The verification is the part worth keeping. Delete one field from one fixture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tsc &lt;span class="nt"&gt;-p&lt;/span&gt; tsconfig.tests.json
&lt;span class="go"&gt;ml.test.ts(17,3): error TS2719: Types of property 'route_profile' are
  incompatible. Type 'string | undefined' is not assignable to type 'string'.

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vitest run packages/scorer/src/ml.test.ts
&lt;span class="go"&gt;      Tests  15 passed (15)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The type checker names the field and the file. The test suite reports fifteen&lt;br&gt;
passes. &lt;strong&gt;A test asserting on a &lt;code&gt;NaN&lt;/code&gt; that flows through the arithmetic without&lt;br&gt;
throwing is not a test failure: it's a test quietly measuring nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A component that produced nothing at all.&lt;/strong&gt; At &lt;code&gt;DEMO_SPEED=8&lt;/code&gt; a 1-hour window is&lt;br&gt;
450 real seconds and the scenario runs for 30 scenario-minutes, half of one&lt;br&gt;
window. Whether &lt;em&gt;any&lt;/em&gt; 1h window closes depends on where the run falls relative to&lt;br&gt;
a boundary. Most runs produce zero. The isolation forest can never fit a 1h model.&lt;br&gt;
A third of the advertised &lt;code&gt;1m/5m/1h&lt;/code&gt; pipeline contributes nothing, and said&lt;br&gt;
nothing about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A duplicated definition that drifted.&lt;/strong&gt; The dashboard keeps its own list of&lt;br&gt;
which clients are hostile, because it's a separate build. That copy drifted twice&lt;br&gt;
while I was adding clients, and the failure is quiet in the dangerous direction:&lt;br&gt;
an unlisted attacker defaults to "legitimate", so its every escalation is scored&lt;br&gt;
as a false positive and the confusion matrix reports numbers that never happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this class specifically
&lt;/h2&gt;

&lt;p&gt;Two structural reasons, and both are common.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline swallows errors.&lt;/strong&gt; Insert failures are caught and dropped, because&lt;br&gt;
a detection layer should not take down the API it protects. That's the right&lt;br&gt;
call, and it converts every write failure into silence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The report was derived from the components it was meant to verify.&lt;/strong&gt; Detection&lt;br&gt;
latency came from the scorer's own records. If the scorer says it flagged&lt;br&gt;
something, the report said it was detected. The one thing that could have&lt;br&gt;
contradicted it, what the gateway actually did, wasn't consulted.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually helped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Make absence visible.&lt;/strong&gt; The report now prints how many windows each size&lt;br&gt;
produced, and says so explicitly when one produced none:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;labelled windows (detection_labels): 197   [1mx160  5mx37  1hx0]
  note: 1h produced no verdicts — the scenario is 30 scenario-minutes long,
  shorter than those windows. Run a longer one with e.g. SCENARIO_MIN=130.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Silent absence is indistinguishable from silent breakage. A component that&lt;br&gt;
contributes nothing should say so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert against the independent record.&lt;/strong&gt; Not the detector's own output. The&lt;br&gt;
access log is what the gateway did; &lt;code&gt;risk_scores&lt;/code&gt; is what the scorer concluded.&lt;br&gt;
They are different facts and deserve different assertions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard the guard.&lt;/strong&gt; The report is now only as trustworthy as the access log it&lt;br&gt;
reads: a new single point of failure. So the agents keep their own request counts&lt;br&gt;
purely to cross-check it, and the report warns when the two disagree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin duplication you can't remove.&lt;/strong&gt; The dashboard's copy of ground truth can't&lt;br&gt;
be eliminated without restructuring the build graph. The &lt;em&gt;drift&lt;/em&gt; can:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lists exactly the attack clients, no more and no fewer&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dashboardAttackers&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;ATTACK_CLIENT_IDS&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&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;strong&gt;Type-check the code you excluded from the build.&lt;/strong&gt; &lt;code&gt;tsconfig.tests.json&lt;/code&gt;, with&lt;br&gt;
&lt;code&gt;noEmit&lt;/code&gt;, wired into &lt;code&gt;pnpm test&lt;/code&gt;. It has caught a stale fixture in every round&lt;br&gt;
since.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question worth asking first
&lt;/h2&gt;

&lt;p&gt;The habit I'd most like to pass on is a change in first instinct.&lt;/p&gt;

&lt;p&gt;When something looks off in a system like this, the reflex is "where's the logic&lt;br&gt;
error?" For six consecutive bugs here, the productive question was different:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Which component is silently doing nothing?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not &lt;em&gt;what is wrong with this calculation&lt;/em&gt;, but &lt;em&gt;is this calculation happening at&lt;br&gt;
all&lt;/em&gt;. Every one of these bugs produced plausible output. Several produced output&lt;br&gt;
that was internally consistent. One reported PASS on the exact scenario it was&lt;br&gt;
failing.&lt;/p&gt;

&lt;p&gt;The corollary, if you build detection systems: your instrumentation is a component&lt;br&gt;
too, and it fails the same way. Ask what your report &lt;em&gt;cannot express&lt;/em&gt;, because&lt;br&gt;
that's the shape of the bug you won't find.&lt;/p&gt;




&lt;p&gt;That's the series. The code is a demonstration artifact, deliberately readable,&lt;br&gt;
deliberately argued with, and honest about the parts that don't work. The&lt;br&gt;
&lt;code&gt;docs/DECISIONS.md&lt;/code&gt; file in the repo is 28 sections, and most of them record&lt;br&gt;
something that was wrong and how it was found. That turned out to be the most&lt;br&gt;
useful thing in it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>debugging</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A graduated response ladder where every rung is invisible</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:57:18 +0000</pubDate>
      <link>https://dev.to/darkedges/a-graduated-response-ladder-where-every-rung-is-invisible-46bc</link>
      <guid>https://dev.to/darkedges/a-graduated-response-ladder-where-every-rung-is-invisible-46bc</guid>
      <description>&lt;p&gt;Detection produces a number. Something has to turn that number into a response,&lt;br&gt;
and the response has two hard constraints that pull against each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It must be &lt;strong&gt;proportionate&lt;/strong&gt;. A score of 55 is not a score of 95, and treating
them the same means either blocking clients you shouldn't or serving attackers
you shouldn't.&lt;/li&gt;
&lt;li&gt;It must be &lt;strong&gt;invisible&lt;/strong&gt;. An attacker who learns they were detected changes
tactics, and you've converted a detection into a training signal for them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second constraint rules out most of the obvious implementations of the first.&lt;/p&gt;
&lt;h2&gt;
  
  
  The ladder
&lt;/h2&gt;

&lt;p&gt;Six tiers, evaluated in Rego, driven by the pushed risk score plus the gateway's&lt;br&gt;
own fast-path signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allow → log → throttle → step_up → deny → revoke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy is small enough to read in full, and mirrors the scorer's thresholds&lt;br&gt;
exactly:&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;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"deny"&lt;/span&gt;     &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;85&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"step_up"&lt;/span&gt;  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;70&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;85&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"throttle"&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;70&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s2"&gt;"log"&lt;/span&gt;      &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;score_fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;score_entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;final_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fast_tier&lt;/span&gt;  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fast_tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;score_tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;final_tier&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;score_tier&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;score_tier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tier_rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fast_tier&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 final decision is the &lt;em&gt;more severe&lt;/em&gt; of two independent signals: the scorer's&lt;br&gt;
composite, and the gateway's own sub-second guardrails. The second exists because&lt;br&gt;
windowed scoring cannot react faster than one window, and a flood needs stopping&lt;br&gt;
before then.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fail open, deliberately
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FAIL-OPEN: when the risk-score data is missing or stale we allow (with a&lt;/span&gt;
&lt;span class="c1"&gt;# logged reason) rather than deny.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the choice most likely to get an argument, so here's the reasoning.&lt;/p&gt;

&lt;p&gt;This is a detection layer bolted onto a production API. If the scorer, the&lt;br&gt;
pipeline, or the data path between them breaks, failing &lt;em&gt;closed&lt;/em&gt; takes the real&lt;br&gt;
API down for every legitimate integration: a self-inflicted outage far more&lt;br&gt;
damaging than the marginal enumeration an attacker achieves during the gap.&lt;/p&gt;

&lt;p&gt;The tradeoff is explicit and bounded: the gateway's fast-path guardrails&lt;br&gt;
(cardinality, miss storm, honeytokens) are independent of the score data and keep&lt;br&gt;
working when it's stale. Failing open on the &lt;em&gt;statistical&lt;/em&gt; layer is not failing&lt;br&gt;
open entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  Covert enforcement
&lt;/h2&gt;

&lt;p&gt;Here's where the second constraint bites.&lt;/p&gt;

&lt;p&gt;Every blocking decision returns a response &lt;strong&gt;byte-identical to a normal upstream&lt;br&gt;
404&lt;/strong&gt;: same status, same body, same headers. The attacker sees the objects they&lt;br&gt;
request "not existing".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MISS_BODY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;not_found&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;One body, used for a genuine 404, a scope-violating 403, and a covert block&lt;br&gt;
alike. This reaches deeper than it first appears:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The resource API's &lt;code&gt;404&lt;/code&gt; and &lt;code&gt;403&lt;/code&gt; are &lt;strong&gt;byte- and timing-identical to each
other&lt;/strong&gt;, with a test that measures both distributions and fails if they're
distinguishable. A 403 that's distinguishable from a 404 is an existence oracle:
"this object exists but you can't see it" is exactly the information an
enumerator wants.&lt;/li&gt;
&lt;li&gt;Honeytoken recognition happens gateway-side (&lt;strong&gt;part 6&lt;/strong&gt;) so the resource API's
response never varies at all.&lt;/li&gt;
&lt;li&gt;Ownership metadata is returned on hits &lt;strong&gt;and&lt;/strong&gt; misses, empty when absent, for
the same reason.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The two rungs that did nothing
&lt;/h2&gt;

&lt;p&gt;For a long time this system decided &lt;code&gt;throttle&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt;, recorded them,&lt;br&gt;
displayed them on a dashboard, and then served the request anyway. Only &lt;code&gt;deny&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;revoke&lt;/code&gt; were enforced.&lt;/p&gt;

&lt;p&gt;A six-rung ladder with two decorative rungs is not a graduated response. It's a&lt;br&gt;
binary one with extra logging.&lt;/p&gt;

&lt;p&gt;Implementing them meant answering: what do these mean when the client must not&lt;br&gt;
notice?&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;throttle&lt;/code&gt; is a budget, not a delay
&lt;/h3&gt;

&lt;p&gt;The instinct is to slow the client down. That's wrong here for two reasons: added&lt;br&gt;
latency is directly measurable by an attacker timing their own requests, and it&lt;br&gt;
defeats the constant-time work in the resource API.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;throttle&lt;/code&gt; serves a budget of 60 requests per rolling minute and returns the&lt;br&gt;
same byte-identical miss beyond it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;throttle&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reqCount1m&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;THROTTLE_BUDGET_PER_MIN&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;miss&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 budget sits comfortably above the legitimate integration's ~60 req/min,&lt;br&gt;
because &lt;code&gt;throttle&lt;/code&gt; is the middle of the ladder where a false positive is still&lt;br&gt;
plausible. It cuts an enumerator's extraction rate by an order of magnitude while&lt;br&gt;
barely touching a client that landed there by mistake.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;step_up&lt;/code&gt; is indistinguishable from token expiry
&lt;/h3&gt;

&lt;p&gt;A step-up challenge that announces itself is useless. So instead: require a token&lt;br&gt;
minted &lt;em&gt;after&lt;/em&gt; the challenge was raised, and treat anything older as an ordinary&lt;br&gt;
expired token.&lt;/p&gt;

&lt;p&gt;Re-authentication is routine for a &lt;code&gt;client_credentials&lt;/code&gt; integration, tokens live&lt;br&gt;
300 seconds here, so a 401 at this point is indistinguishable from the expiry the&lt;br&gt;
client already handles. The legitimate client re-mints and never notices it was&lt;br&gt;
challenged.&lt;/p&gt;

&lt;p&gt;What step-up buys is a distinction no other tier can draw: between &lt;strong&gt;holding a&lt;br&gt;
stolen bearer token&lt;/strong&gt; and &lt;strong&gt;holding the credentials&lt;/strong&gt;. A token thief cannot mint a&lt;br&gt;
replacement and stops dead.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; stop an attacker with the client secret, and every attacker in&lt;br&gt;
this demo is modelled that way: they simply re-authenticate. Worth stating&lt;br&gt;
plainly rather than dressing up. The residual value is that forced re-issuance&lt;br&gt;
drives the token-issuance rate up, which the scorer weights: the mitigation feeds&lt;br&gt;
the detector even when it fails to block.&lt;/p&gt;

&lt;p&gt;That claim went untested for far too long, which was the wrong way round for the&lt;br&gt;
argument the whole tier rests on. It's now pinned directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lets the credential holder through and stops the thief permanently&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;step_up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;tokenIssuedAtMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stolenTokenIssuedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// challenge raised&lt;/span&gt;

  &lt;span class="nx"&gt;tokenIssuedAtMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stolenTokenIssuedAt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// re-authenticated&lt;/span&gt;
  &lt;span class="nf"&gt;expect&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;tokenIssuedAtMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stolenTokenIssuedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// thief, same token&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;expect&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus one more: the stolen token stays refused &lt;strong&gt;even after the client's tier drops&lt;br&gt;
back&lt;/strong&gt;, or a thief could simply wait out the score.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two ordering bugs, both found by getting them wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The challenge cleared itself.&lt;/strong&gt; JWT &lt;code&gt;iat&lt;/code&gt; has one-second resolution, so "was&lt;br&gt;
this token minted after the challenge?" is ambiguous within the raising second.&lt;br&gt;
My first implementation granted a second of grace, which accepted the very token&lt;br&gt;
the challenge was meant to retire. The challenge raised and satisfied itself on&lt;br&gt;
the same request.&lt;/p&gt;

&lt;p&gt;The fix is to round the challenge timestamp &lt;em&gt;up&lt;/em&gt; to the next whole second.&lt;br&gt;
Ambiguity resolves in the safe direction: a token from the challenge's own second&lt;br&gt;
is treated as too old, and the client mints another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A recoverable 401 pre-empted a hard block.&lt;/strong&gt; An earlier version answered an&lt;br&gt;
outstanding challenge immediately, before the upstream call. That both leaked&lt;br&gt;
timing: a challenged request returned faster than a served one, and let a&lt;br&gt;
recoverable 401 take precedence over a &lt;code&gt;deny&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now nothing short-circuits. Every post-authentication request takes the same&lt;br&gt;
path, proxy upstream, then overwrite the response, so served, throttled, denied&lt;br&gt;
and challenged requests are indistinguishable by timing. That's the same pattern&lt;br&gt;
&lt;code&gt;deny&lt;/code&gt; already used; I'd introduced an inconsistency rather than following it.&lt;/p&gt;
&lt;h2&gt;
  
  
  A challenge is not a block
&lt;/h2&gt;

&lt;p&gt;One measurement distinction that turned out to matter a lot.&lt;/p&gt;

&lt;p&gt;The demo's ambiguous automation reaches &lt;code&gt;step_up&lt;/code&gt;, is challenged, re-authenticates&lt;br&gt;
transparently, and carries on, 597 of 604 requests served. Early on my assertion&lt;br&gt;
counted &lt;code&gt;step_up&lt;/code&gt; responses alongside &lt;code&gt;deny&lt;/code&gt; as "blocked", so this reported as a&lt;br&gt;
&lt;strong&gt;failure&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It was reporting the ladder &lt;em&gt;working correctly&lt;/em&gt; as though it had blocked an&lt;br&gt;
innocent client.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;denied&lt;/code&gt; and &lt;code&gt;challenged&lt;/code&gt; are now separate columns, and the verdict asserts the&lt;br&gt;
client was never &lt;em&gt;denied&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  ambiguous automation escalated to log/throttle/step_up but was never denied
        (5 challenges, all recovered), and still had 598/603 requests served
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being challenged and recovering is the ladder succeeding. If your metrics can't&lt;br&gt;
express that difference, a graduated response will look like a false-positive&lt;br&gt;
generator and someone will simplify it back into a binary one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ladder is for
&lt;/h2&gt;

&lt;p&gt;The demo's ambiguous client is legitimate. Rigid machine timing, several egress&lt;br&gt;
hosts, scaling out. Genuinely ambiguous evidence.&lt;/p&gt;

&lt;p&gt;It climbs &lt;code&gt;log → throttle → step_up&lt;/code&gt;, keeps every request served, and is never&lt;br&gt;
hard-blocked. It's an honest false positive, and the demo counts it as one.&lt;/p&gt;

&lt;p&gt;That's the argument for graduated response in one client: &lt;strong&gt;you will be wrong&lt;br&gt;
about someone, and the design question is what happens when you are.&lt;/strong&gt; A binary&lt;br&gt;
detector answers "cut them off". A ladder answers "slow them down, ask them to&lt;br&gt;
prove it, and let them carry on when they do", which is survivable for both&lt;br&gt;
sides.&lt;/p&gt;




&lt;p&gt;Next, and last: &lt;strong&gt;when your detector lies to you&lt;/strong&gt;: the bug class that dominated&lt;br&gt;
this project, where a component does nothing, raises no error, and leaves every&lt;br&gt;
number looking plausible.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>architecture</category>
      <category>opa</category>
    </item>
    <item>
      <title>Honeytokens that recognise themselves: stateless decoys with automatic attribution</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:57:06 +0000</pubDate>
      <link>https://dev.to/darkedges/honeytokens-that-recognise-themselves-stateless-decoys-with-automatic-attribution-f2a</link>
      <guid>https://dev.to/darkedges/honeytokens-that-recognise-themselves-stateless-decoys-with-automatic-attribution-f2a</guid>
      <description>&lt;p&gt;Every signal in the previous articles is statistical. They weigh evidence, they&lt;br&gt;
have thresholds, they can be argued with.&lt;/p&gt;

&lt;p&gt;Honeytokens are different in kind. A honeytoken is a record that &lt;strong&gt;does not&lt;br&gt;
exist and was never given to anyone&lt;/strong&gt;. Nothing legitimate can ask for it, because&lt;br&gt;
nothing legitimate has ever held a reference to it. A request for one isn't&lt;br&gt;
suspicious, it's proof that someone is guessing or working from a stolen list.&lt;/p&gt;

&lt;p&gt;That makes it the highest-confidence signal available, and worth building&lt;br&gt;
carefully.&lt;/p&gt;
&lt;h2&gt;
  
  
  Derivation: make the decoy recognise itself
&lt;/h2&gt;

&lt;p&gt;The obvious implementation is a table. Generate decoy IDs, store them, and check&lt;br&gt;
every miss against the table.&lt;/p&gt;

&lt;p&gt;That has two problems. It puts a database lookup in the request path on every&lt;br&gt;
miss, and misses are exactly what a flood produces. And it doesn't tell you&lt;br&gt;
&lt;em&gt;whose&lt;/em&gt; decoy was tripped without another join.&lt;/p&gt;

&lt;p&gt;Instead, derive them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;honeytokenFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;honeytokenSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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;clientId&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;generation&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;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;uuidFromBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&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="mi"&gt;16&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;Three properties fall out of this, and they're the whole design:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recognition is stateless.&lt;/strong&gt; Given any ID and any client, recompute the client's&lt;br&gt;
decoy set and check membership. No lookup, no cache, no round trip. The gateway&lt;br&gt;
precomputes each known client's set at startup into a &lt;code&gt;Set&lt;/code&gt; and membership is&lt;br&gt;
O(1).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attribution is automatic.&lt;/strong&gt; The client ID is &lt;em&gt;inside&lt;/em&gt; the derivation. There is&lt;br&gt;
no "which client did this decoy belong to?" question, a decoy for&lt;br&gt;
&lt;code&gt;integration-acme&lt;/code&gt; is not a decoy for anyone else, and cannot be. If a decoy&lt;br&gt;
seeded into acme's scope is requested by a different credential, that's&lt;br&gt;
information too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They're format-identical to real IDs.&lt;/strong&gt; The output is shaped as a v4 UUID,&lt;br&gt;
with correct version and variant nibbles, so it is indistinguishable from a real&lt;br&gt;
&lt;code&gt;documents&lt;/code&gt; identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uuidFromBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&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="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mh"&gt;0x40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// version 4&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x3f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// variant 10&lt;/span&gt;
  &lt;span class="err"&gt;…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your decoys are distinguishable from real identifiers, they are not decoys.&lt;br&gt;
An attacker who can filter them out gets a free map of what to avoid.&lt;/p&gt;
&lt;h2&gt;
  
  
  Recognition happens at the gateway, and only on a miss
&lt;/h2&gt;

&lt;p&gt;Two placement decisions, both load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only on a miss.&lt;/strong&gt; The check runs after the upstream call, only when the&lt;br&gt;
response is ≥ 400. A honeytoken is not in the dataset, so requesting one produces&lt;br&gt;
an ordinary miss from the resource API. Checking before would mean checking every&lt;br&gt;
request; checking after means checking only the ones that could possibly be a&lt;br&gt;
decoy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At the gateway, not in the resource API.&lt;/strong&gt; This is the subtle one, and it comes&lt;br&gt;
out of the covert-enforcement requirement from &lt;strong&gt;part 1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the resource API recognised honeytokens, its response would have to &lt;em&gt;do&lt;/em&gt;&lt;br&gt;
something different, and any difference, even in timing, is observable. By&lt;br&gt;
keeping recognition in the gateway, a honeytoken request is, to the resource API,&lt;br&gt;
an ordinary miss. Its response never varies. The gateway then makes a sub-second&lt;br&gt;
deterministic decision on top of a response that carries no signal at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;isMiss&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;documents&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;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isAllowlisted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;honeyIndex&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;clientId&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&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;honeytokenHit&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hit is an immediate &lt;code&gt;revoke&lt;/code&gt;, regardless of accumulated score. The demo's breach&lt;br&gt;
client has a score of essentially zero when it trips one, that's the point. This&lt;br&gt;
is the one signal that doesn't need history.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rotation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rotated_at&lt;/code&gt; sat in the schema for a long time with nothing writing to it. That's&lt;br&gt;
worth fixing, because rotation isn't hygiene theatre, it addresses a specific&lt;br&gt;
failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A decoy list leaks.&lt;/strong&gt; Through a backup, a support ticket, a former employee, a&lt;br&gt;
misconfigured export. And a decoy an attacker can identify is &lt;em&gt;worse than none&lt;/em&gt;,&lt;br&gt;
because they route around it silently and you now have a tripwire you believe in&lt;br&gt;
that will never fire.&lt;/p&gt;

&lt;p&gt;Generations make retirement wholesale: bump &lt;code&gt;HONEYTOKEN_GENERATION&lt;/code&gt; and every&lt;br&gt;
decoy changes, because the generation is inside the HMAC input.&lt;/p&gt;

&lt;p&gt;The design decision worth arguing with is what happens to the &lt;em&gt;old&lt;/em&gt; set. The&lt;br&gt;
gateway keeps recognising the &lt;strong&gt;previous&lt;/strong&gt; generation even though it is no longer&lt;br&gt;
seeded anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;activeHoneytokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;honeytokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&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;generation&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;honeytokenSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generation&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="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&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 reasoning: nothing legitimate ever requests a retired decoy. It was never a&lt;br&gt;
real record, and no client holds a reference to one. Something asking for one is&lt;br&gt;
replaying a stolen list or guessing: both worth knowing about. Retiring&lt;br&gt;
recognition at the same instant as seeding would hand an attacker a window in&lt;br&gt;
which yesterday's decoys are free to probe.&lt;/p&gt;

&lt;p&gt;Recognition stops at two generations, so it can't grow without bound.&lt;/p&gt;
&lt;h2&gt;
  
  
  The allowlist, and what it does not buy
&lt;/h2&gt;

&lt;p&gt;Here's a failure mode that's easy to miss until it happens to you.&lt;/p&gt;

&lt;p&gt;Your security team runs a scanner. A scanner's job is to probe for exactly the&lt;br&gt;
things honeytokens are: identifiers that shouldn't resolve. Without an&lt;br&gt;
exemption, its first sweep trips a decoy and &lt;strong&gt;revokes your own security team's&lt;br&gt;
credential&lt;/strong&gt;, and the failure looks like a successful detection, so nobody&lt;br&gt;
investigates.&lt;/p&gt;

&lt;p&gt;So recognition is allowlisted for scanner identities. Verified on a real run: 21&lt;br&gt;
decoy requests, &lt;strong&gt;0 recognised, 0 honeytoken hits, never revoked&lt;/strong&gt;, while a&lt;br&gt;
non-allowlisted client tripped 3 and was revoked immediately.&lt;/p&gt;

&lt;p&gt;But, and this is the part I got wrong first, the exemption is &lt;strong&gt;much narrower&lt;br&gt;
than its name suggests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I added a scanner agent and asserted it was never blocked, the assertion&lt;br&gt;
failed. Twice. The scanner was still denied: first by the miss-ratio guardrail&lt;br&gt;
when it probed aggressively, then, after I made its behaviour more realistic, by&lt;br&gt;
&lt;code&gt;novelty_run_length&lt;/code&gt;, because sweeping an ID range &lt;em&gt;is&lt;/em&gt; the mimicry signature.&lt;/p&gt;

&lt;p&gt;Both are correct. A scanner sweeping for non-existent records is genuinely&lt;br&gt;
indistinguishable from an enumerator. There is no clever feature that separates&lt;br&gt;
them, because there is no difference in the traffic.&lt;/p&gt;

&lt;p&gt;So the demo now asserts what the allowlist actually promises, decoy probes are&lt;br&gt;
not recognised and never cause a revoke, and prints a note when the scanner is&lt;br&gt;
denied on other grounds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ PASS  internal scanner's decoy probes were not recognised (0 honeytoken hits, never revoked)
        note: it was still denied on other grounds (113/418 requests) — sweeping an ID
        range looks like enumeration, and the allowlist does not cover that
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operational lesson is worth more than a green tick: &lt;strong&gt;allowlisting an identity&lt;br&gt;
for honeytokens buys it no blanket immunity.&lt;/strong&gt; Scanners need exempting at the&lt;br&gt;
policy level too, or they need to not look like attackers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Honeytokens as training labels
&lt;/h2&gt;

&lt;p&gt;One last use, which is the most valuable and the least obvious.&lt;/p&gt;

&lt;p&gt;If you're exporting a dataset to train a model on, you have a labelling problem:&lt;br&gt;
in production you don't know which windows were attacks. Simulation labels are&lt;br&gt;
available only in simulation and are useless for a model you intend to deploy.&lt;/p&gt;

&lt;p&gt;Honeytoken hits are different in kind. They are a &lt;strong&gt;high-confidence positive label&lt;br&gt;
that a real deployment also has&lt;/strong&gt;, because nothing but an enumerator ever requests&lt;br&gt;
an ID that was never issued to anyone.&lt;/p&gt;

&lt;p&gt;So the exported dataset carries them as a separate column from the simulation&lt;br&gt;
label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;window&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;start&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="k"&gt;predicted&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ml&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;anomaly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;honeytoken&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;positive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;label&lt;/code&gt; is the simulation's ground truth. &lt;code&gt;honeytoken_positive&lt;/code&gt; is the one you'd&lt;br&gt;
actually have. A model trained on the second is a model you could ship.&lt;/p&gt;

&lt;p&gt;That reframes what honeytokens are for. They're not only a tripwire, they're the&lt;br&gt;
one source of ground truth an anomaly detector in production can get for free.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;enforcement&lt;/strong&gt;, a six-rung graduated ladder where every rung has to be&lt;br&gt;
invisible to the client it's applied to, and the discovery that two of them were&lt;br&gt;
doing nothing at all.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>cryptography</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Four ways a baseline quietly destroys the anomaly detector built on it</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:56:56 +0000</pubDate>
      <link>https://dev.to/darkedges/four-ways-a-baseline-quietly-destroys-the-anomaly-detector-built-on-it-3n4j</link>
      <guid>https://dev.to/darkedges/four-ways-a-baseline-quietly-destroys-the-anomaly-detector-built-on-it-3n4j</guid>
      <description>&lt;p&gt;Every anomaly detector answers one question: &lt;em&gt;compared to what?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That comparison, the baseline, is where I lost the most time on this project,&lt;br&gt;
and every failure had the same signature. Nothing errored. No test went red. The&lt;br&gt;
numbers stayed plausible. The detector just quietly stopped detecting.&lt;/p&gt;

&lt;p&gt;Four of them, in the order I found them.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. The peer group contained the client it was judging
&lt;/h2&gt;

&lt;p&gt;Cold-start clients have no history, so they're compared against a pool of other&lt;br&gt;
clients' recent benign windows. Reasonable.&lt;/p&gt;

&lt;p&gt;The pool was keyed by feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeatureKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every benign window every client produced went into the pool that client was&lt;br&gt;
later compared against. Including itself.&lt;/p&gt;

&lt;p&gt;So a client could &lt;strong&gt;define its own normality&lt;/strong&gt;. Feed in enough windows and any&lt;br&gt;
behaviour becomes unremarkable, which is precisely the cold-start attacker the&lt;br&gt;
layer exists to catch.&lt;/p&gt;

&lt;p&gt;What made me look was not reasoning, it was an experiment that wouldn't sit&lt;br&gt;
still. I was trying to build a demo client that reliably landed in the middle of&lt;br&gt;
the response ladder, and holding the traffic shape fixed while changing only the&lt;br&gt;
request interval flipped the outcome between &lt;code&gt;allow&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gap=500ms  origins=5  →  allow  (peak 0)
gap=700ms  origins=5  →  step_up (peak 83)
gap=800ms  origins=5  →  allow  (peak 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A knife edge like that is never a tuning problem. The outcome depended on a race&lt;br&gt;
between a client's own samples reaching the pool and the pool being consulted.&lt;/p&gt;

&lt;p&gt;Fix: key the pool per client, and exclude the client under evaluation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;byClient&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;clientId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;excludeClientId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// this is what "peer" means&lt;/span&gt;
  &lt;span class="err"&gt;…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards the behaviour became monotone in the actual evidence, and identical at&lt;br&gt;
every request interval:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;origins&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;3&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;5&lt;/th&gt;
&lt;th&gt;6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;peak score&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tier&lt;/td&gt;
&lt;td&gt;allow&lt;/td&gt;
&lt;td&gt;log&lt;/td&gt;
&lt;td&gt;throttle&lt;/td&gt;
&lt;td&gt;step_up&lt;/td&gt;
&lt;td&gt;deny&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if a parameter that shouldn't matter changes the outcome, stop tuning&lt;br&gt;
and go find the defect. Knife edges are symptoms.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Baselines weren't keyed by window size
&lt;/h2&gt;

&lt;p&gt;Same investigation, second defect. The per-client history was keyed by&lt;br&gt;
&lt;code&gt;(clientId, feature)&lt;/code&gt;, with no window size.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;req_rate&lt;/code&gt; over a minute and &lt;code&gt;req_rate&lt;/code&gt; over an hour are not the same quantity.&lt;br&gt;
Neither is &lt;code&gt;distinct_src_ips&lt;/code&gt;, or anything else. All three window sizes were&lt;br&gt;
pooling into one array, producing a baseline that described nothing.&lt;/p&gt;

&lt;p&gt;It also filled the "enough samples to use own history" threshold three times too&lt;br&gt;
fast, so clients switched off the peer baseline long before they had a meaningful&lt;br&gt;
one of their own.&lt;/p&gt;

&lt;p&gt;An unglamorous bug, and the kind that hides indefinitely because every individual&lt;br&gt;
number still looks sane.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The boiling frog
&lt;/h2&gt;

&lt;p&gt;This one is my favourite, because the code documented the correct behaviour and&lt;br&gt;
then didn't implement it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scorer.ts&lt;/code&gt; said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A window is treated as benign (and folded into baselines) only well below the&lt;/span&gt;
&lt;span class="c1"&gt;// throttle tier, so attack windows never poison the peer baseline, a client's&lt;/span&gt;
&lt;span class="c1"&gt;// own history, or the isolation forest's training set.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BENIGN_MAX_SCORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;learn()&lt;/code&gt; did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;learn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeatureVector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;benign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&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;feature&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="err"&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;ownHist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// ← unconditional&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;benign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;peerHist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Own history was updated regardless. The peer pool was protected; the client's own&lt;br&gt;
baseline was not.&lt;/p&gt;

&lt;p&gt;The consequence is a textbook boiling frog. A sustained anomaly gets absorbed&lt;br&gt;
into the client's own history within &lt;code&gt;MIN_OWN_SAMPLES&lt;/code&gt; windows, the robust&lt;br&gt;
z-score collapses toward zero, and the layer falls silent on &lt;strong&gt;exactly the thing&lt;br&gt;
it was watching&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Measured before and after, on an anomalous client run for 26 windows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;before: flagged for &lt;strong&gt;6&lt;/strong&gt; windows, then never again&lt;/li&gt;
&lt;li&gt;after: flagged for &lt;strong&gt;23&lt;/strong&gt; of 26&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is one line, &lt;code&gt;if (!benign) return;&lt;/code&gt;, and the tradeoff is real and worth&lt;br&gt;
stating: a persistently flagged client now never rebuilds its own baseline and&lt;br&gt;
keeps being measured against its peers until it behaves normally again. For a&lt;br&gt;
false positive that means staying flagged. That's survivable here only because&lt;br&gt;
the response is graduated: &lt;code&gt;throttle&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt; are recoverable, so a&lt;br&gt;
wrongly-flagged client is slowed and challenged, not cut off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; when a comment states an invariant, that's a test waiting to be&lt;br&gt;
written. This one was a lie for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The all-time set that should have been a trailing window
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;novelty_ratio&lt;/code&gt;, the share of object IDs a client hasn't seen before, needs a&lt;br&gt;
set of previously-seen IDs. Mine was a &lt;code&gt;Set&amp;lt;string&amp;gt;&lt;/code&gt; that only ever grew.&lt;/p&gt;

&lt;p&gt;It was wrong twice over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unbounded memory.&lt;/strong&gt; One entry per distinct resource ID, per client, per window&lt;br&gt;
size. The &lt;code&gt;sequential&lt;/code&gt; attacker alone adds ~4,800 in a single demo run. Every&lt;br&gt;
other baseline in the pipeline was capped: the route mix, the seasonal history,&lt;br&gt;
the peer pool, the z-score history, the token tracker's eviction. This one, the&lt;br&gt;
largest of them, was not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong semantics.&lt;/strong&gt; The spec defines novelty against the &lt;em&gt;trailing 7 days&lt;/em&gt;. Mine&lt;br&gt;
was all-time, so nothing ever became novel again and novelty decayed monotonically&lt;br&gt;
toward zero.&lt;/p&gt;

&lt;p&gt;The second one is the dangerous one, and it's a slow-acting poison:&lt;br&gt;
&lt;code&gt;novelty_run_length&lt;/code&gt; is the only thing that catches &lt;code&gt;mimicry&lt;/code&gt;, and it requires&lt;br&gt;
&lt;code&gt;novelty_ratio ≥ 0.15&lt;/code&gt;. On a long-running deployment that threshold quietly stops&lt;br&gt;
being reachable, and mimicry detection degrades to nothing. A thirty-minute demo&lt;br&gt;
cannot show it. Nothing would ever have raised a complaint.&lt;/p&gt;

&lt;p&gt;Fix: a trailing horizon with a hard cap behind it.&lt;/p&gt;

&lt;p&gt;And writing the test for it immediately found a &lt;em&gt;second&lt;/em&gt; bug: the horizon was&lt;br&gt;
applied &lt;strong&gt;after&lt;/strong&gt; the window was scored, so a window could be measured against IDs&lt;br&gt;
that had already expired. It's applied first now; the question a window asks is&lt;br&gt;
"what has this client seen in the trailing 7d &lt;em&gt;as of now&lt;/em&gt;".&lt;/p&gt;

&lt;p&gt;That's worth pausing on. The test didn't just confirm the fix; it found a&lt;br&gt;
different bug in the fix. The behaviour was subtle enough that I'd have shipped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. (Bonus) The feedback loop I created myself
&lt;/h2&gt;

&lt;p&gt;Not a baseline exactly, but the same family, and the most instructive because I&lt;br&gt;
built it deliberately and it took a measurement to notice.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;step_up&lt;/code&gt; tier forces a client to re-authenticate. Re-authentication raises&lt;br&gt;
&lt;code&gt;token_issuance_rate&lt;/code&gt;. The scorer weights &lt;code&gt;token_issuance_rate&lt;/code&gt;. A higher score&lt;br&gt;
escalates the client to &lt;code&gt;deny&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So: the system challenges a client → the client complies → the system convicts it&lt;br&gt;
for complying.&lt;/p&gt;

&lt;p&gt;The demo's ambiguous automation was being denied at a score of 88, with &lt;strong&gt;+21 of&lt;br&gt;
it coming from the token issuance its own challenge had caused.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is to mark issuances made under an outstanding challenge and exclude&lt;br&gt;
them: the same reasoning that keeps the policy's own &lt;code&gt;decision&lt;/code&gt; out of the&lt;br&gt;
detection projection. Feeding your own output back in as a feature lets the&lt;br&gt;
detector confirm its own suspicions: one spurious escalation justifies the next,&lt;br&gt;
and the score drifts away from the traffic it's supposed to describe.&lt;/p&gt;

&lt;p&gt;After the fix that client peaked at 66 instead of 88, and stopped being denied.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these have in common
&lt;/h2&gt;

&lt;p&gt;None of the five raised an error. None turned a test red. In every case the&lt;br&gt;
system kept producing scores that looked entirely reasonable.&lt;/p&gt;

&lt;p&gt;Three practical habits came out of it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure before you tune.&lt;/strong&gt; Three of these were found because a number moved when&lt;br&gt;
it shouldn't have, or didn't move when it should. Every time I reached for a&lt;br&gt;
threshold first, the threshold was the wrong tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat comments as unwritten tests.&lt;/strong&gt; The boiling frog was documented correctly&lt;br&gt;
and implemented wrongly, and lived for months in a file I'd read a dozen times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask what the baseline contains.&lt;/strong&gt; Almost every failure here was the baseline&lt;br&gt;
absorbing the thing it was meant to measure against: the client itself, its own&lt;br&gt;
anomalies, its own history without bound, or the system's own output. If you build&lt;br&gt;
one of these, write down explicitly what the comparison population is, what it&lt;br&gt;
excludes, and how it ages. Then check the code agrees.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;honeytokens&lt;/strong&gt;: decoys derived so they recognise themselves and attribute&lt;br&gt;
their own trips, why recognition lives in the gateway, and what an allowlist&lt;br&gt;
does and doesn't buy.&lt;/p&gt;

</description>
      <category>security</category>
      <category>machinelearning</category>
      <category>debugging</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Three detection layers that disagree usefully, and why they combine by max, not sum</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:56:37 +0000</pubDate>
      <link>https://dev.to/darkedges/three-detection-layers-that-disagree-usefully-and-why-they-combine-by-max-not-sum-24gf</link>
      <guid>https://dev.to/darkedges/three-detection-layers-that-disagree-usefully-and-why-they-combine-by-max-not-sum-24gf</guid>
      <description>&lt;p&gt;Features get you a vector per window. Turning that into a decision is where the&lt;br&gt;
design choices are.&lt;/p&gt;

&lt;p&gt;This system scores every window three independent ways and takes the strongest&lt;br&gt;
single case. Each layer covers a failure mode of the others.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 1: guardrails
&lt;/h2&gt;

&lt;p&gt;Deterministic thresholds, no baseline of any kind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Honeytoken hit — highest-confidence signal. Immediate revoke.&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;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;honeytoken_hits&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;add&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;honeytoken_hits&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;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// High miss ratio — guessing IDs that mostly do not exist.&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;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;miss_ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;miss_ratio&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;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Sequential walk — near-adjacent IDs in order.&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;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id_sequentiality&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;distinct_resource_ids&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id_sequentiality&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;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Working set that expands and never stops — the mimicry signature.&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;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;window_size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1m&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;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;novelty_run_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="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;novelty_run_length&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;…&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;Being baseline-free is the point: they fire on a client's &lt;em&gt;first&lt;/em&gt; window. A&lt;br&gt;
statistical layer needs history to say anything, so a brand-new compromised&lt;br&gt;
integration, one that never had a quiet period to learn from, is invisible to&lt;br&gt;
it. Guardrails cover exactly that gap.&lt;/p&gt;

&lt;p&gt;The deliberate omission is cardinality. There is no "distinct IDs &amp;gt; N" guardrail&lt;br&gt;
in the scorer, for the reasons in &lt;strong&gt;part 3&lt;/strong&gt;: it false-positives on legitimate&lt;br&gt;
bulk reads and no threshold fixes that. (The gateway's &lt;em&gt;fast path&lt;/em&gt; does have a&lt;br&gt;
cardinality rule, at 150 distinct/minute: well above any realistic backfill, and&lt;br&gt;
it exists to stop a flood before the first window closes.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 2: robust statistics
&lt;/h2&gt;

&lt;p&gt;Per client, per feature, per window size: keep a bounded history and score new&lt;br&gt;
values with a &lt;strong&gt;median/MAD robust z-score&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Median and MAD rather than mean and standard deviation, because mean and σ are&lt;br&gt;
themselves distorted by the outliers you're hunting. One 5,000-request window&lt;br&gt;
drags a mean enough to make the next one look normal.&lt;/p&gt;

&lt;p&gt;Three things make this work in practice, and each was added after watching it&lt;br&gt;
misbehave without them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A scale floor per feature.&lt;/strong&gt; A client whose &lt;code&gt;interarrival_cv&lt;/code&gt; has been exactly&lt;br&gt;
0.55 for a week has a MAD of ~0, so any deviation divides by nearly zero and&lt;br&gt;
produces an enormous z. The floor is the smallest deviation worth treating as&lt;br&gt;
meaningful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;robustZFloored&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;history&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&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;med&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&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;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;medianAbsoluteDeviation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;floor&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;scale&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;med&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;scale&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain &lt;code&gt;robustZ&lt;/code&gt; returns 0 when the history has no spread at all, which is exactly&lt;br&gt;
backwards for the case you care about: a perfectly regular client that jumps 10×&lt;br&gt;
is the &lt;em&gt;strongest&lt;/em&gt; possible signal, not the weakest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate and a cap.&lt;/strong&gt; Deviations below |z| = 2.5 contribute nothing; above 8 they&lt;br&gt;
stop growing. Normal variation is not evidence, and one absurd outlier shouldn't&lt;br&gt;
dominate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single-feature cap.&lt;/strong&gt; If only one feature is anomalous, the total is capped&lt;br&gt;
below the escalation threshold. One lone signal doesn't escalate; it needs&lt;br&gt;
corroboration.&lt;/p&gt;

&lt;p&gt;Directions are explicit per feature, because they aren't uniform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DIRECTION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeatureKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Dir&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;interarrival_cv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;:&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="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// LOW is suspicious&lt;/span&gt;
  &lt;span class="na"&gt;id_sequentiality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&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="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;miss_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;             &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&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="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;subject_mismatch_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&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="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;distinct_src_ips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sign&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="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Peer groups
&lt;/h3&gt;

&lt;p&gt;A client with no history of its own gets compared against its peers. But "peers"&lt;br&gt;
has to mean something, pooling &lt;em&gt;every&lt;/em&gt; other client means a search integration&lt;br&gt;
is judged against a bulk sync and both look odd.&lt;/p&gt;

&lt;p&gt;Clients are grouped by &lt;strong&gt;route profile&lt;/strong&gt;: the smallest set of routes covering&lt;br&gt;
most of their traffic, sorted. Two candidate keys were rejected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Request volume&lt;/strong&gt;, it would place a flooding attacker among the busiest
clients, which is exactly where it looks least remarkable. Grouping by
something the attacker controls hands them the choice of jury.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full route set&lt;/strong&gt;: too sparse. Nearly every client ends up alone, and a
peer group of one is the client itself under another name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The profile is taken from a client's &lt;em&gt;benign history&lt;/em&gt;, not the window being&lt;br&gt;
scored, so an attacker can't change groups by changing endpoints mid-attack.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 3: an extended isolation forest
&lt;/h2&gt;

&lt;p&gt;The first two layers each ask a one-dimensional question. Both are structurally&lt;br&gt;
blind to a window that is unremarkable on &lt;strong&gt;every axis individually&lt;/strong&gt; but sits&lt;br&gt;
somewhere no legitimate client has ever been &lt;em&gt;jointly&lt;/em&gt;. That joint structure is&lt;br&gt;
the only reason to add a third layer.&lt;/p&gt;

&lt;p&gt;The obvious choice is a classic isolation forest (Liu et al.): build trees that&lt;br&gt;
split on one randomly-chosen feature at a random value, and measure how quickly a&lt;br&gt;
point gets isolated. Anomalies are few and different, so they isolate near the&lt;br&gt;
root.&lt;/p&gt;

&lt;p&gt;I measured it on this project's own feature space rather than assuming:&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;inliers&lt;/th&gt;
&lt;th&gt;jointly-novel window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;axis-parallel splits&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.51&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hyperplane splits&lt;/td&gt;
&lt;td&gt;0.44&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.66&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The classic algorithm gave &lt;strong&gt;no separation at all&lt;/strong&gt; on the case the layer exists&lt;br&gt;
for. That's not a tuning failure, it's structural: isolating a point &lt;em&gt;between&lt;/em&gt;&lt;br&gt;
two clusters requires a specific conjunction of axis cuts that a random tree&lt;br&gt;
rarely finds, so it scores no higher than the edges of the clusters themselves.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;strong&gt;Extended Isolation Forest&lt;/strong&gt; (Hariri et al.): split on a random&lt;br&gt;
&lt;em&gt;hyperplane&lt;/em&gt;, a random normal vector and a random intercept, so one cut can&lt;br&gt;
carve an oblique region.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Draw a random hyperplane. Only dimensions that vary in this subsample get a&lt;/span&gt;
&lt;span class="c1"&gt;// non-zero coefficient — a constant dimension would just shift the intercept.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dims&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&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;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;d&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;active&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;max&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gaussian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="o"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because a hyperplane mixes dimensions, features must be &lt;strong&gt;standardised&lt;/strong&gt; first.&lt;br&gt;
Without that, &lt;code&gt;req_count&lt;/code&gt; (in the thousands) dominates every dot product and the&lt;br&gt;
forest collapses back into a one-dimensional detector.&lt;/p&gt;
&lt;h3&gt;
  
  
  Calibration, not constants
&lt;/h3&gt;

&lt;p&gt;An isolation-forest score has no absolute meaning. It shifts with dimensionality,&lt;br&gt;
subsample size, the depth limit, and the shape of whatever benign population&lt;br&gt;
exists. My first implementation used a literal threshold of &lt;code&gt;0.62&lt;/code&gt; and was simply&lt;br&gt;
wrong for this feature space: every real anomaly landed below it.&lt;/p&gt;

&lt;p&gt;So the threshold is read off the model's &lt;strong&gt;own training scores&lt;/strong&gt;: the 99th&lt;br&gt;
percentile, with a floor at 0.5 (the forest's own reference point, below which a&lt;br&gt;
window isolates &lt;em&gt;more slowly&lt;/em&gt; than average and cannot be an outlier).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trainScores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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;r&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;forest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&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;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trainScores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;GATE_FLOOR&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;saturation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MIN_RAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p99&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p50&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly 1% of benign windows clear it, whatever the absolute numbers turn out to&lt;br&gt;
be.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two deliberate constraints
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The model is frozen once fitted.&lt;/strong&gt; Continuously refitting on whatever currently&lt;br&gt;
looks benign is how an anomaly detector gets poisoned: a patient attacker whose&lt;br&gt;
early windows score below the benign threshold gets absorbed into the definition&lt;br&gt;
of normal, and the model then &lt;em&gt;defends&lt;/em&gt; their behaviour. The cost is drift, in&lt;br&gt;
production this needs a periodic, human-reviewed refresh against a vetted clean&lt;br&gt;
period.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ML score is capped below the deny tier.&lt;/strong&gt; A forest tells you &lt;em&gt;how strange&lt;/em&gt;,&lt;br&gt;
never &lt;em&gt;why&lt;/em&gt;. It may escalate for attention; a blocking decision should rest on a&lt;br&gt;
signal a human can read off the attribution.&lt;/p&gt;

&lt;p&gt;And an honest blind spot: the forest cannot learn "this feature must stay&lt;br&gt;
constant", because a zero-variance dimension offers nothing to split on.&lt;br&gt;
&lt;code&gt;distinct_src_ips&lt;/code&gt; is 1 for every window of a well-behaved integration, so 25 of&lt;br&gt;
them is invisible &lt;em&gt;here&lt;/em&gt;. That's a division of labour rather than a gap,&lt;br&gt;
constant-in-baseline features are exactly where a median/MAD z-score with a&lt;br&gt;
scale floor excels, and layer 2 weights them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Combining: max, not sum
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;composite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This surprises people, so: the three layers are &lt;strong&gt;three readings of the same&lt;br&gt;
evidence, not three independent pieces of it&lt;/strong&gt;. A sequential walk shows up as a&lt;br&gt;
guardrail trip, a timing anomaly, &lt;em&gt;and&lt;/em&gt; an isolation-forest outlier, all&lt;br&gt;
describing one fact. Summing them triple-counts it.&lt;/p&gt;

&lt;p&gt;Taking the max means the composite is always "the strongest case any single layer&lt;br&gt;
can make". That's also what keeps attribution honest: the score and the reason for&lt;br&gt;
it come from the same place. With a sum you get 94 points from three partial&lt;br&gt;
explanations and nothing you can put in front of a human.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attribution is the point
&lt;/h2&gt;

&lt;p&gt;Every score carries per-feature attribution: which features contributed how many&lt;br&gt;
points, with their values and z-scores. It's persisted to ClickHouse so any past&lt;br&gt;
decision can be reconstructed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score=88 tier=deny
  miss ratio 0.90 over 280 reqs
  miss_ratio anomalous (z=18.1, +64)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the forest, which has no native notion of per-feature contribution, this is&lt;br&gt;
done by &lt;strong&gt;ablation&lt;/strong&gt;: replace one feature with its training median ("what if this&lt;br&gt;
had been typical?") and measure how much of the anomaly disappears. Contributions&lt;br&gt;
are normalised to the points actually awarded, so the attribution always adds up&lt;br&gt;
to the score it explains.&lt;/p&gt;

&lt;p&gt;The reason this matters isn't UX. An unexplained score cannot be argued with, and&lt;br&gt;
a detection nobody can argue with is a detection nobody will act on. The first&lt;br&gt;
time an on-call engineer is paged at 3am by "client X scored 87", the only useful&lt;br&gt;
next question is &lt;em&gt;why&lt;/em&gt;, and if the answer is "the model said so", the system gets&lt;br&gt;
turned off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tiers
&lt;/h2&gt;

&lt;p&gt;The composite maps to a graduated ladder, mirrored exactly in the Rego policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  0–29  allow
 30–49  log
 50–69  throttle
 70–84  step_up
 85+    deny
        revoke   (never from score alone — reserved for hard signals)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;revoke&lt;/code&gt; is unreachable by score. It's reserved for honeytoken hits and explicit&lt;br&gt;
revocation, because those are categorically different kinds of evidence.&lt;/p&gt;

&lt;p&gt;What each rung actually &lt;em&gt;does&lt;/em&gt;, and the discovery that two of them did nothing at&lt;br&gt;
all for months, is &lt;strong&gt;article 7&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;the four ways a baseline quietly destroyed the detector built on it&lt;/strong&gt;.&lt;br&gt;
The most transferable article in the series.&lt;/p&gt;

</description>
      <category>security</category>
      <category>machinelearning</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The most obvious signals for detecting enumeration don't work</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:55:42 +0000</pubDate>
      <link>https://dev.to/darkedges/the-most-obvious-signals-for-detecting-enumeration-dont-work-4fo6</link>
      <guid>https://dev.to/darkedges/the-most-obvious-signals-for-detecting-enumeration-dont-work-4fo6</guid>
      <description>&lt;p&gt;Every window of traffic, per credential, this system computes about twenty&lt;br&gt;
features. Six of them drive decisions. The rest are computed, stored, displayed,&lt;br&gt;
and deliberately never allowed to escalate anything.&lt;/p&gt;

&lt;p&gt;That split is the most useful thing in this article, so let's get to why.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;You want to detect someone reading your whole user table. The obvious signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;distinct_resource_ids&lt;/code&gt;&lt;/strong&gt;, how many distinct objects were touched&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;novelty_ratio&lt;/code&gt;&lt;/strong&gt;, what share had never been touched by this client before&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;repeat_ratio&lt;/code&gt;&lt;/strong&gt;, what share had been&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An enumerator maxes all three: thousands of objects, nearly all new, almost no&lt;br&gt;
repeats. It looks damning.&lt;/p&gt;

&lt;p&gt;Now run a legitimate post-deploy backfill through the same features. Thousands of&lt;br&gt;
objects. Nearly all new. Almost no repeats.&lt;/p&gt;

&lt;p&gt;They are &lt;strong&gt;identical&lt;/strong&gt;. Not similar, identical in shape, because both are "read a&lt;br&gt;
large number of records this client has not read before". Cardinality is a measure&lt;br&gt;
of &lt;em&gt;how much&lt;/em&gt; was read, and how much is exactly the thing the two have in common.&lt;/p&gt;

&lt;p&gt;I tried it anyway, with corroboration from other signals, because it felt like it&lt;br&gt;
should be salvageable. It fired on backfill windows whenever the timing jitter&lt;br&gt;
happened to dip. There is no threshold that separates them, because the&lt;br&gt;
distributions overlap by construction.&lt;/p&gt;
&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;Stop asking how much was read. Ask &lt;strong&gt;how it was accessed&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A backfill reads records &lt;strong&gt;in its own key order&lt;/strong&gt;, whatever order its job
queue produced. An enumerator walks the ID space.&lt;/li&gt;
&lt;li&gt;A backfill reads records that &lt;strong&gt;exist&lt;/strong&gt;; it has a list. An enumerator guesses,
and guesses wrong most of the time.&lt;/li&gt;
&lt;li&gt;A backfill is driven by a system with queues and retries, so its timing has
&lt;strong&gt;jitter&lt;/strong&gt;. A tight loop does not.&lt;/li&gt;
&lt;li&gt;A backfill runs from &lt;strong&gt;one place&lt;/strong&gt;. A credential being replayed doesn't.&lt;/li&gt;
&lt;li&gt;A backfill &lt;strong&gt;ends&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those is a feature, and each carries the distinction that cardinality&lt;br&gt;
doesn't.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;id_sequentiality&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The fraction of consecutive requested IDs whose absolute gap is ≤ 2, in arrival&lt;br&gt;
order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;idSequentiality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idsInOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&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;nums&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&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;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;idsInOrder&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&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;nums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nums&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&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;adjacent&lt;/span&gt; &lt;span class="o"&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;for &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;i&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;nums&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="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;adjacent&lt;/span&gt;&lt;span class="o"&gt;++&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;adjacent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nums&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="mi"&gt;1&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;Near 1.0 for an ID walk, near 0 for random access or hot-object reads. Note it&lt;br&gt;
only applies to integer-like IDs, which is the whole argument for opaque&lt;br&gt;
identifiers, and why the demo's dataset deliberately has both integer (&lt;code&gt;users&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;orders&lt;/code&gt;) and UUID (&lt;code&gt;documents&lt;/code&gt;) resource types.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;miss_ratio&lt;/code&gt; and &lt;code&gt;not_found_ratio&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Share of responses ≥ 400, and share that are 404. An integration reading records&lt;br&gt;
it has references to has a miss ratio near zero. A guesser's is near one. This is&lt;br&gt;
the single most reliable signal in the system and it's almost embarrassingly&lt;br&gt;
simple.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;interarrival_cv&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Coefficient of variation (σ/μ) of the gaps between requests. Low CV means&lt;br&gt;
machine-regular. Legitimate traffic sits around 0.5–0.7; a tight loop is&lt;br&gt;
essentially 0.&lt;/p&gt;

&lt;p&gt;Note the direction is &lt;strong&gt;inverted&lt;/strong&gt; here: &lt;em&gt;low&lt;/em&gt; is suspicious. That's worth&lt;br&gt;
flagging because most anomaly-detection plumbing assumes high-is-bad, and this&lt;br&gt;
feature is a reminder to make direction an explicit per-feature property rather&lt;br&gt;
than an assumption.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;distinct_src_ips&lt;/code&gt; / &lt;code&gt;distinct_asns&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;One credential appearing from many origins. Catches the &lt;code&gt;distributed&lt;/code&gt; profile,&lt;br&gt;
where per-origin rate limiting sees nothing because each IP issues a trickle.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;endpoint_kl_divergence&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Kullback–Leibler divergence between this window's route mix and the client's own&lt;br&gt;
trailing route mix, in bits. A dictionary attack pivots traffic onto the search&lt;br&gt;
endpoint, and KL measures exactly that pivot.&lt;/p&gt;

&lt;p&gt;The implementation detail that matters is &lt;strong&gt;smoothing&lt;/strong&gt;. A route the client has&lt;br&gt;
never used before would produce infinite divergence, which would swamp every&lt;br&gt;
other feature forever. Additive smoothing on the baseline makes it large but&lt;br&gt;
finite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&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;qTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;q&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="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An integration hitting a brand-new endpoint should register as &lt;em&gt;surprising&lt;/em&gt;, not&lt;br&gt;
as an unbounded outlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;subject_mismatch_ratio&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The one that catches an attack nothing else sees.&lt;/p&gt;

&lt;p&gt;Consider a compromised integration holding a genuine delegation for one user.&lt;br&gt;
Every request carries a valid token, valid scopes, and a &lt;code&gt;subject&lt;/code&gt; it really is&lt;br&gt;
entitled to act for. Per-request authorization has nothing to object to: a token&lt;br&gt;
that may act for Alice is acting for Alice.&lt;/p&gt;

&lt;p&gt;What it cannot see is that Alice's delegation is being used to read &lt;em&gt;everyone&lt;br&gt;
else's&lt;/em&gt; records.&lt;/p&gt;

&lt;p&gt;So the resource API reports who owns each record, the gateway records that beside&lt;br&gt;
the subject, and the feature is the share of delegated requests where the two&lt;br&gt;
differ. An honest on-behalf-of integration reads the record of the person it's&lt;br&gt;
acting for, so this sits at zero.&lt;/p&gt;

&lt;p&gt;Measured on the first window of the &lt;code&gt;obo-sweep&lt;/code&gt; profile: &lt;code&gt;miss_ratio 0&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;subject_mismatch_ratio 1.0&lt;/code&gt;, caught on &lt;code&gt;subject_mismatch_ratio (z=20.0, +72)&lt;/code&gt;&lt;br&gt;
alone, with no other signal contributing anything.&lt;/p&gt;

&lt;p&gt;One implementation note with a security edge: the owner header is set on hits&lt;br&gt;
&lt;strong&gt;and&lt;/strong&gt; misses, empty when there's nothing to report. Present only on hits, it&lt;br&gt;
would turn the response shape into an existence oracle.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;novelty_run_length&lt;/code&gt;, the feature about time
&lt;/h3&gt;

&lt;p&gt;This is the one that finally caught &lt;code&gt;mimicry&lt;/code&gt;, and it's the most interesting&lt;br&gt;
feature in the system because it isn't about the shape of a window at all.&lt;/p&gt;

&lt;p&gt;Mimicry hides enumeration inside replayed legitimate traffic. Normal rate, normal&lt;br&gt;
timing, valid IDs, no ordering, miss ratio diluted below every threshold. Against&lt;br&gt;
the feature set above it scored &lt;strong&gt;zero on every window&lt;/strong&gt;, because every one of&lt;br&gt;
those features describes a single window, and in any single window mimicry &lt;em&gt;is&lt;/em&gt;&lt;br&gt;
legitimate traffic.&lt;/p&gt;

&lt;p&gt;What separates it from a benign bulk read is not shape but &lt;strong&gt;duration&lt;/strong&gt;. A&lt;br&gt;
backfill ends. An ongoing compromise does not.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;novelty_run_length&lt;/code&gt; counts consecutive windows in which the client's working&lt;br&gt;
set kept growing, and a guardrail fires at 20: far beyond any plausible backfill&lt;br&gt;
episode. Detection is correspondingly slow, and the project claims no latency&lt;br&gt;
bound for it.&lt;/p&gt;

&lt;p&gt;Two design details worth arguing with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is deliberately &lt;strong&gt;not&lt;/strong&gt; corroborated by a low repeat ratio. Mimicry's method
is to keep re-reading the hot set as cover, which holds repeat ratio &lt;em&gt;high&lt;/em&gt;;
requiring a low one would exclude precisely the traffic it exists to catch. (I
had this wrong first, and the guardrail excluded its own target.)&lt;/li&gt;
&lt;li&gt;It is evaluated &lt;strong&gt;only on the 1-minute window&lt;/strong&gt;. Over longer windows a
legitimate client's occasional stale references accumulate, each is an ID never
seen before, until its novelty ratio never returns to baseline and the run
stops distinguishing anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest limitation: an integration whose legitimate job is to page through an&lt;br&gt;
ever-growing corpus trips this, and needs an explicit exemption rather than a&lt;br&gt;
cleverer threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The features that are computed and never scored
&lt;/h2&gt;

&lt;p&gt;This is the part I'd most like to convince you of.&lt;/p&gt;

&lt;p&gt;Several features are computed every window, written to ClickHouse, exported in&lt;br&gt;
the training set, and shown in the attribution breakdown, but are &lt;strong&gt;excluded from&lt;br&gt;
the score&lt;/strong&gt;. That is a design decision, not an unfinished TODO.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;distinct_resource_ids&lt;/code&gt;, &lt;code&gt;novelty_ratio&lt;/code&gt;, &lt;code&gt;repeat_ratio&lt;/code&gt;&lt;/strong&gt;, the trap from the&lt;br&gt;
top of this article. They establish that a bulk access &lt;em&gt;is happening&lt;/em&gt;, which is&lt;br&gt;
useful context for a human, and they cannot separate the two hypotheses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hour_of_day_zscore&lt;/code&gt;&lt;/strong&gt;, how unusual this window's volume is for this client at&lt;br&gt;
this time of week. Tempting, and wrong: an off-schedule volume spike is precisely&lt;br&gt;
what a legitimate post-deploy backfill looks like. Weighting it would buy nothing&lt;br&gt;
but false positives on the one case that most needs to be right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;distinct_subjects&lt;/code&gt;&lt;/strong&gt;: how many principals a credential acted for. Saturates&lt;br&gt;
for any legitimate bulk job, exactly as cardinality does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;replayed_jti&lt;/code&gt;&lt;/strong&gt;: the share of traffic riding a token seen from more than one&lt;br&gt;
host. This is the shape of a stolen bearer token, and I very much wanted it to&lt;br&gt;
work. It's excluded because I measured it: weighted at all, a perfectly benign&lt;br&gt;
client, one that mints a single token and shares it across its own workers,&lt;br&gt;
scored &lt;strong&gt;100 and was denied, with +53 of that coming from this feature alone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One token from several hosts is the shape of theft &lt;em&gt;and&lt;/em&gt; the shape of a shared&lt;br&gt;
token cache. Separating them needs origin-relationship data (AS ownership,&lt;br&gt;
geography) this system doesn't model. So it's recorded for a human to act on,&lt;br&gt;
which is not the same as the system acting on it.&lt;/p&gt;

&lt;p&gt;That last one is the general pattern, and it's worth stating plainly: &lt;strong&gt;the&lt;br&gt;
signals that look most damning are often the ones that saturate for some entirely&lt;br&gt;
ordinary behaviour.&lt;/strong&gt; The only reason this one didn't ship as a false-positive&lt;br&gt;
generator is that the weight was measured against a benign client before being&lt;br&gt;
kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windows
&lt;/h2&gt;

&lt;p&gt;Everything above is computed at 1m, 5m and 1h, per client, in tumbling windows.&lt;/p&gt;

&lt;p&gt;Multiple scales are not redundancy. &lt;code&gt;slow-and-low&lt;/code&gt; issues about two requests a&lt;br&gt;
minute, so its 1m windows fall below the minimum request count and score nothing&lt;br&gt;
at all, its signal only exists at 5m and 1h. Conversely &lt;code&gt;novelty_run_length&lt;/code&gt; is&lt;br&gt;
only meaningful at 1m.&lt;/p&gt;

&lt;p&gt;Different attacks are visible at different scales, and that is the entire&lt;br&gt;
premise of the pipeline. It's also the premise that a bug quietly violated for&lt;br&gt;
months, which is article 8.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;three scoring layers&lt;/strong&gt;, why they combine by maximum rather than sum, and&lt;br&gt;
why per-feature attribution is the point rather than a nice-to-have.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>datascience</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Simulating attackers is easy. Simulating legitimate users is the hard part</title>
      <dc:creator>DarkEdges</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:55:05 +0000</pubDate>
      <link>https://dev.to/darkedges/simulating-attackers-is-easy-simulating-legitimate-users-is-the-hard-part-bjk</link>
      <guid>https://dev.to/darkedges/simulating-attackers-is-easy-simulating-legitimate-users-is-the-hard-part-bjk</guid>
      <description>&lt;p&gt;In &lt;strong&gt;part 1&lt;/strong&gt; I argued that enumeration from a valid credential can only be&lt;br&gt;
caught from the shape of the request sequence, and that the hard requirement is&lt;br&gt;
not catching attackers but &lt;em&gt;not catching everyone else&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That requirement has a consequence people skip: to have any confidence in a false&lt;br&gt;
positive rate, you need legitimate traffic that is genuinely hard to distinguish&lt;br&gt;
from an attack. Traffic that is obviously benign proves nothing. The detector in&lt;br&gt;
this project drives ten synthetic clients through one gateway, all holding real&lt;br&gt;
tokens with identical scopes, and only five of them are attackers.&lt;/p&gt;

&lt;p&gt;Here is the whole cast, and what each one is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legitimate integration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;integration-acme&lt;/code&gt; models a real third-party integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zipf-distributed reads&lt;/strong&gt; of a hot object set: real integrations re-read the
same handful of records constantly, they don't sample uniformly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;human-ish jitter&lt;/strong&gt; between requests (300–1700ms), because a real system has
queues and retries and network variance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a small burst at the top of each hour&lt;/strong&gt; from a batch sync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;occasional genuine 404s&lt;/strong&gt; from stale references it still holds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;on-behalf-of reads&lt;/strong&gt;: when it fetches user 812's record it does so on behalf
of user 812&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters and I'll come back to it in article 3.&lt;/p&gt;

&lt;p&gt;And then, deliberately, the thing that makes the whole project difficult:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;a benign bulk backfill&lt;/strong&gt;, a post-deploy job that reads 9,000 records it has
never touched, over five scenario-minutes, as fast as the API will serve them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the false-positive stress test, and every detection rule has to survive&lt;br&gt;
it. As shown in part 1, on cardinality, novelty ratio and repeat ratio a backfill&lt;br&gt;
is &lt;em&gt;indistinguishable&lt;/em&gt; from an attacker dumping the same table.&lt;/p&gt;

&lt;p&gt;The demo asserts &lt;code&gt;integration-acme&lt;/code&gt; stays in &lt;code&gt;allow&lt;/code&gt; for the entire run, backfill&lt;br&gt;
included. Zero blocked requests. If that assertion ever fails, the detector is&lt;br&gt;
worthless regardless of what it catches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attackers
&lt;/h2&gt;

&lt;p&gt;Five profiles, all using a credential of the same type and scopes as the real&lt;br&gt;
integration. None of them exploit a bug; every one is using access it legitimately&lt;br&gt;
holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sequential&lt;/code&gt;&lt;/strong&gt;: walks integer IDs in order, fast and machine-regular. The&lt;br&gt;
naive attacker. Trips the fast-path cardinality guardrail and the windowed&lt;br&gt;
sequentiality guardrail within seconds. It's in the cast mainly as a control: if&lt;br&gt;
your detector can't catch this, stop reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dictionary&lt;/code&gt;&lt;/strong&gt;: probes &lt;code&gt;/users?email=&lt;/code&gt; from a wordlist of plausible corporate&lt;br&gt;
addresses. Structurally different: there is no ID ordering to detect, so&lt;br&gt;
&lt;code&gt;id_sequentiality&lt;/code&gt; never fires. What gives it away is the miss ratio (almost every&lt;br&gt;
guess is wrong) and the route mix pivoting onto the search endpoint. About one&lt;br&gt;
guess in twenty lands on a real account, which is exactly why the profile is worth&lt;br&gt;
running: a blind guesser still harvests real data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;slow-and-low&lt;/code&gt;&lt;/strong&gt;: one request every 30 seconds, random IDs across a large&lt;br&gt;
range so ~99% miss. Its whole design is to stay below every rate threshold. It&lt;br&gt;
must be caught by accumulation over the coarser windows, never by rate. This&lt;br&gt;
profile later exposed the most consequential bug in the project (article 8).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;distributed&lt;/code&gt;&lt;/strong&gt;: one credential replayed from six IPs across six ASNs,&lt;br&gt;
re-authenticating from each. Per-origin rate limiting sees nothing: each IP&lt;br&gt;
issues a trickle. The credential is the only thing tying the requests together,&lt;br&gt;
which is precisely the axis the detector aggregates on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;mimicry&lt;/code&gt;&lt;/strong&gt;: the hard one. Enumeration hidden inside replayed legitimate&lt;br&gt;
traffic on the same credential: for every probe it issues several requests that&lt;br&gt;
look exactly like the real integration's work, with the same Zipf re-reads, the&lt;br&gt;
same jitter, the same route mix. Rate rules see nothing, the miss ratio is diluted&lt;br&gt;
below the guardrail threshold, and interleaved hot reads destroy the ordering&lt;br&gt;
signal.&lt;/p&gt;

&lt;p&gt;Measured against the detector as it stood before I designed for it: &lt;strong&gt;mimicry&lt;br&gt;
scored zero on every single window.&lt;/strong&gt; Not "low": zero. Every feature described&lt;br&gt;
the shape of one window, and in any one window mimicry &lt;em&gt;is&lt;/em&gt; legitimate traffic.&lt;br&gt;
Article 3 covers the feature that finally caught it, which had to be about&lt;br&gt;
duration rather than shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;obo-sweep&lt;/code&gt;&lt;/strong&gt;: a compromised integration holding one genuine delegation. Every&lt;br&gt;
request carries a valid token, valid scopes, and a &lt;code&gt;subject&lt;/code&gt; it really is entitled&lt;br&gt;
to act for. It uses that single delegation to read everybody else's records. No&lt;br&gt;
enumeration signal fires at all: the IDs are valid so there's no miss ratio, they&lt;br&gt;
are strided so there's no ordering, the rate is ordinary. This one is caught by a&lt;br&gt;
signal nothing else in the system provides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;honeytoken breach&lt;/code&gt;&lt;/strong&gt;: requests a decoy object from a cold start, with zero&lt;br&gt;
accumulated score. Tests the instant-revoke path (article 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  The two that aren't attackers, and aren't quite normal either
&lt;/h2&gt;

&lt;p&gt;These are the interesting ones, and both were added late, after the detector&lt;br&gt;
started behaving in ways I couldn't explain.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ambiguous automation
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;integration-paced&lt;/code&gt; is &lt;strong&gt;legitimate&lt;/strong&gt;. It re-reads a small fixed set of records it&lt;br&gt;
is entitled to, never probes a missing ID, never walks IDs in order. What makes it&lt;br&gt;
look wrong is that it is obviously a machine, rigid timing, zero jitter, running&lt;br&gt;
from several egress hosts at once, and gradually adding more of them as it scales&lt;br&gt;
out. A monitoring bot, or a sync worker being horizontally scaled.&lt;/p&gt;

&lt;p&gt;That is genuinely ambiguous evidence, and it exists to answer a question the rest&lt;br&gt;
of the cast doesn't ask: &lt;strong&gt;what should the system do when it isn't sure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer should not be a block. It climbs &lt;code&gt;log → throttle → step_up&lt;/code&gt;, keeps&lt;br&gt;
working the whole time, and is never hard-blocked. When it reaches &lt;code&gt;step_up&lt;/code&gt; it&lt;br&gt;
answers the challenge by re-authenticating: transparently, because that's what&lt;br&gt;
any competent OAuth2 client does when its token is rejected, and carries on.&lt;/p&gt;

&lt;p&gt;It is also, honestly, a &lt;strong&gt;false positive&lt;/strong&gt;, and the demo counts it as one. A&lt;br&gt;
detector that never admits to any is not being measured.&lt;/p&gt;

&lt;h3&gt;
  
  
  The internal scanner
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;internal-scanner&lt;/code&gt; is your own security team's tooling, and it exists because of&lt;br&gt;
a specific failure mode: a scanner's job is to probe for exactly the things&lt;br&gt;
honeytokens are: identifiers that shouldn't resolve. Without an exemption, its&lt;br&gt;
first sweep trips a decoy and &lt;strong&gt;revokes your own security team's credential&lt;/strong&gt;, and&lt;br&gt;
the failure looks like a successful detection.&lt;/p&gt;

&lt;p&gt;So honeytoken recognition is allowlisted for scanner identities. Verified in a&lt;br&gt;
run: 21 decoy requests, &lt;strong&gt;0 recognised, 0 honeytoken hits, never revoked&lt;/strong&gt;, while&lt;br&gt;
the non-allowlisted breach client tripped 3 hits and was revoked immediately.&lt;/p&gt;

&lt;p&gt;But the exemption turned out to be narrower than its name suggests, and the demo&lt;br&gt;
now says so out loud. The scanner is still &lt;em&gt;denied&lt;/em&gt;, for sweeping an ID range,&lt;br&gt;
which is the mimicry signature. That's correct: a scanner sweeping for&lt;br&gt;
non-existent records is genuinely indistinguishable from an enumerator. The&lt;br&gt;
operational lesson is worth more than a green tick: allowlisting an identity for&lt;br&gt;
honeytokens buys it no blanket immunity, and scanners need exempting at the policy&lt;br&gt;
level too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the boring clients matter more
&lt;/h2&gt;

&lt;p&gt;If I'd built only the attackers, I would have shipped a detector that scores 100%&lt;br&gt;
recall and is unusable.&lt;/p&gt;

&lt;p&gt;Three of this project's most significant bugs were found only because a&lt;br&gt;
non-attacking client was in the cast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;ambiguous automation&lt;/strong&gt; existing at all is what forced me to check whether
the middle tiers of the response ladder did anything. They didn't, &lt;code&gt;throttle&lt;/code&gt;
and &lt;code&gt;step_up&lt;/code&gt; were being decided, recorded, displayed on a dashboard, and then
the request was served anyway.&lt;/li&gt;
&lt;li&gt;Trying to make that client land reliably in the middle band exposed &lt;strong&gt;four
separate baseline defects&lt;/strong&gt; (article 5), including one where changing nothing
but the request interval flipped the outcome between &lt;code&gt;allow&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;scanner&lt;/strong&gt; exposed that a defensive feature wired into the gateway since
the beginning had no test and no traffic exercising it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are findable with attackers alone. Attackers make the detector fire;&lt;br&gt;
only realistic non-attackers tell you whether firing meant anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism
&lt;/h2&gt;

&lt;p&gt;Everything is seeded. Every agent takes an explicit RNG seed, the dataset is&lt;br&gt;
generated from a fixed seed, and the isolation forest is seeded per window size.&lt;br&gt;
Two runs of the same scenario produce the same traffic.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds. When you are chasing a detector that behaves&lt;br&gt;
differently between runs, you need to know the &lt;em&gt;traffic&lt;/em&gt; is not the variable.&lt;br&gt;
That confidence is what let me establish, in article 5, that an inexplicable&lt;br&gt;
flip-flop between &lt;code&gt;allow&lt;/code&gt; and &lt;code&gt;step_up&lt;/code&gt; was a defect in the baseline rather than&lt;br&gt;
noise in the input.&lt;/p&gt;

&lt;p&gt;What is &lt;em&gt;not&lt;/em&gt; deterministic is timing under load, and I'll be honest about it here&lt;br&gt;
because it bit repeatedly: all ten agents share one Node event loop. On an idle&lt;br&gt;
machine the &lt;code&gt;sequential&lt;/code&gt; profile is caught in 29–50 scenario-seconds; on a box at&lt;br&gt;
load average 8.5 the same code reports 74–76 and fails its bound. That's the&lt;br&gt;
harness, not the detector, but it means "did my change cause a regression?" has&lt;br&gt;
to be answered on a quiet machine.&lt;/p&gt;




&lt;p&gt;Next: &lt;strong&gt;which features actually discriminate&lt;/strong&gt;, why the most obvious ones don't,&lt;br&gt;
and the ones this system computes and deliberately never scores.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>testing</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
