Skip to content

Adapters inject the honeytoken link themselves (#482) - #15

Merged
cport1 merged 1 commit into
mainfrom
feat/auto-inject-honeytoken-482
Jul 31, 2026
Merged

Adapters inject the honeytoken link themselves (#482)#15
cport1 merged 1 commit into
mainfrom
feat/auto-inject-honeytoken-482

Conversation

@cport1

@cport1 cport1 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #482.

The SDK generated a honeytoken and asked the developer to embed it. Almost nobody did:

sdk_tripwire rows in production, ever 4
bot_scanner rows 3,897

The WordPress plugin injects the link itself and has real coverage. That's the whole difference.

Why this is the signal worth having

A trap hit needs no score, no JavaScript, no fingerprint and no IP — the client asked for a path that exists nowhere and is linked only from an element no human can see.

It's also the only signal that scores. Honeypot hits are weighted 38% against user-agent's 1%, because a user agent is trivially spoofed. Measured against production: every rule-less sdk detection ever recorded came out at 0, while sdk_tripwire averaged 52.5.

0.7.0 armed the tripwire paths by default. This adds the bait that leads anything to them.

The token is derived, not random

honeytoken() mints a random path per call — fine when a developer holds the object and wires both halves, useless for automatic injection: the adapter would advertise a different path per request, and a second replica would arm a path the first never served. A crawler following the bait would trip nothing.

siteHoneytoken() derives it by HMAC of a label under the API key, exactly as the WordPress plugin derives it from a per-site secret. Every process computes the same path without coordinating. Rotation with a one-day grace window is available and off by default — a CDN-cached page can outlive the window, at which point the trap silently stops catching anything.

Express injects; Next can't, and says so

Express wraps res.write/res.end and rewrites the finished document. Every guard is a way this could corrupt a customer's response, which is far worse than a missed detection:

  • non-HTML untouched, so an anchor never lands in JSON
  • committed responses left alone
  • Content-Length corrected, or the client truncates the body
  • anything thrown falls back to the original write

Next middleware cannot do this. It runs before the route handler and the App Router streams the RSC payload straight to the client — there's no complete document to rewrite, and buffering one would defeat streaming. Partial support would be worse than none: a trap that works in development and silently stops under the rendering mode most production apps use is exactly the failure this issue is about. Next gets an honest one-liner for the root layout, rendered from linkProps as ordinary JSX rather than dangerouslySetInnerHTML.

Accessibility and robots are the point

nofollow/noindex and off-screen positioning keep robots-honouring crawlers away — a trap that catches Googlebot files the customer's own search traffic as an attack. aria-hidden and tabindex="-1" keep it out of the accessibility tree and tab order, without which this punishes people for using assistive technology. Both asserted.

Link text is escaped. It's our own config today, and that's how a value ends up templated from a database field two refactors later.

Acceptance criteria

  • A fresh Express app produces a working trap with no code beyond enabling honeytokens — tested end to end: fetch the page, extract the injected path, request it, assert the tripwire fires and an ordinary page doesn't
  • The link is absent from JSON and non-HTML responses
  • Tests assert the accessibility and robots attributes

Next is documented rather than automatic, for the reason above.

Testing

199 tests green (turbo test --force), check:edge clean. New: 15 unit tests on derivation/injection/escaping, plus 8 through a real Express server covering JSON integrity, Content-Length, opt-out, no-apiKey, path stability across requests, and the full bait→trap loop.

The SDK generated a honeytoken and asked the developer to embed it. Almost
nobody did: sdk_tripwire has FOUR rows in production, ever, against
bot_scanner's 3,897. The WordPress plugin injects the link itself and has real
coverage, which is the whole difference.

WHY THIS IS THE SIGNAL WORTH HAVING

A trap hit needs no score, no JavaScript, no fingerprint and no IP — the client
asked for a path that exists nowhere and is linked only from an element no human
can see. It is also the only signal that scores: honeypot hits are weighted 38%
against user-agent's 1%, because a user agent is trivially spoofed. Measured
against production, every rule-less `sdk` detection ever recorded came out at 0
while sdk_tripwire averaged 52.5.

0.7.0 armed the tripwire PATHS by default. This adds the bait that leads
anything to them.

THE TOKEN IS DERIVED, NOT RANDOM

honeytoken() mints a random path per call, which is fine when a developer holds
the object and wires both halves. It is useless for automatic injection: the
adapter would advertise a different path per request, and a second replica would
arm a path the first never served. A crawler following the bait would trip
nothing.

So siteHoneytoken() derives it by HMAC of a label under the API key, exactly as
the WordPress plugin derives it from a per-site secret. Every process computes
the same path without coordinating, and nothing extra is stored. Rotation with a
one-day grace window is available and off by default — a CDN-cached page can
outlive the window, at which point the trap silently stops catching anything.

EXPRESS INJECTS; NEXT CANNOT, AND SAYS SO

Express wraps res.write/res.end and rewrites the finished document. Every guard
there is a way this could corrupt a customer's response, which is a far worse
failure than a missed detection: non-HTML is untouched so an anchor never lands
in JSON, a committed response is left alone, Content-Length is corrected or the
client truncates the body, and anything thrown falls back to the original write.

Next middleware runs before the route handler and the App Router streams the RSC
payload straight to the client — there is no complete document to rewrite, and
buffering one would defeat streaming. Partial support would be worse than none:
a trap that works in development and silently stops working under the rendering
mode most production apps use is exactly the failure this issue is about. Next
gets an honest one-liner for the root layout instead, rendered from linkProps as
ordinary JSX rather than dangerouslySetInnerHTML.

ACCESSIBILITY AND ROBOTS ARE THE POINT, NOT DECORATION

nofollow/noindex and off-screen positioning keep robots-honouring crawlers away:
a trap that catches Googlebot files the customer's own search traffic as an
attack. aria-hidden and tabindex=-1 keep it out of the accessibility tree and
tab order, without which this feature punishes people for using assistive
technology. Both are asserted.

The link text is escaped. It is our own config today, and that is how a value
ends up templated from a database field two refactors later.
@cport1
cport1 merged commit c707f2d into main Jul 31, 2026
2 checks passed
@cport1
cport1 deleted the feat/auto-inject-honeytoken-482 branch July 31, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant