Every signal in the previous articles is statistical. They weigh evidence, they
have thresholds, they can be argued with.
Honeytokens are different in kind. A honeytoken is a record that does not
exist and was never given to anyone. Nothing legitimate can ask for it, because
nothing legitimate has ever held a reference to it. A request for one isn't
suspicious, it's proof that someone is guessing or working from a stolen list.
That makes it the highest-confidence signal available, and worth building
carefully.
Derivation: make the decoy recognise itself
The obvious implementation is a table. Generate decoy IDs, store them, and check
every miss against the table.
That has two problems. It puts a database lookup in the request path on every
miss, and misses are exactly what a flood produces. And it doesn't tell you
whose decoy was tripped without another join.
Instead, derive them:
export function honeytokenFor(clientId: string, n: number, generation = 0): string {
const mac = createHmac('sha256', config.honeytokenSecret)
.update(`${clientId}:${generation}:${n}`)
.digest();
return uuidFromBytes(mac.subarray(0, 16));
}
Three properties fall out of this, and they're the whole design:
Recognition is stateless. Given any ID and any client, recompute the client's
decoy set and check membership. No lookup, no cache, no round trip. The gateway
precomputes each known client's set at startup into a Set and membership is
O(1).
Attribution is automatic. The client ID is inside the derivation. There is
no "which client did this decoy belong to?" question, a decoy for
integration-acme is not a decoy for anyone else, and cannot be. If a decoy
seeded into acme's scope is requested by a different credential, that's
information too.
They're format-identical to real IDs. The output is shaped as a v4 UUID,
with correct version and variant nibbles, so it is indistinguishable from a real
documents identifier:
export function uuidFromBytes(bytes: Buffer): string {
const b = Buffer.from(bytes.subarray(0, 16));
b[6] = (b[6]! & 0x0f) | 0x40; // version 4
b[8] = (b[8]! & 0x3f) | 0x80; // variant 10
…
}
If your decoys are distinguishable from real identifiers, they are not decoys.
An attacker who can filter them out gets a free map of what to avoid.
Recognition happens at the gateway, and only on a miss
Two placement decisions, both load-bearing.
Only on a miss. The check runs after the upstream call, only when the
response is ≥ 400. A honeytoken is not in the dataset, so requesting one produces
an ordinary miss from the resource API. Checking before would mean checking every
request; checking after means checking only the ones that could possibly be a
decoy.
At the gateway, not in the resource API. This is the subtle one, and it comes
out of the covert-enforcement requirement from part 1.
If the resource API recognised honeytokens, its response would have to do
something different, and any difference, even in timing, is observable. By
keeping recognition in the gateway, a honeytoken request is, to the resource API,
an ordinary miss. Its response never varies. The gateway then makes a sub-second
deterministic decision on top of a response that carries no signal at all.
if (
isMiss &&
match.resource_type === 'documents' &&
match.resource_id &&
!isAllowlisted(clientId) &&
honeyIndex.get(clientId)?.has(match.resource_id)
) {
honeytokenHit = true;
}
A hit is an immediate revoke, regardless of accumulated score. The demo's breach
client has a score of essentially zero when it trips one, that's the point. This
is the one signal that doesn't need history.
Rotation
rotated_at sat in the schema for a long time with nothing writing to it. That's
worth fixing, because rotation isn't hygiene theatre, it addresses a specific
failure.
A decoy list leaks. Through a backup, a support ticket, a former employee, a
misconfigured export. And a decoy an attacker can identify is worse than none,
because they route around it silently and you now have a tripwire you believe in
that will never fire.
Generations make retirement wholesale: bump HONEYTOKEN_GENERATION and every
decoy changes, because the generation is inside the HMAC input.
The design decision worth arguing with is what happens to the old set. The
gateway keeps recognising the previous generation even though it is no longer
seeded anywhere:
export function activeHoneytokenSet(clientId: string, count: number, generation = 0): string[] {
const current = honeytokenSet(clientId, count, generation);
return generation > 0
? [...current, ...honeytokenSet(clientId, count, generation - 1)]
: current;
}
The reasoning: nothing legitimate ever requests a retired decoy. It was never a
real record, and no client holds a reference to one. Something asking for one is
replaying a stolen list or guessing: both worth knowing about. Retiring
recognition at the same instant as seeding would hand an attacker a window in
which yesterday's decoys are free to probe.
Recognition stops at two generations, so it can't grow without bound.
The allowlist, and what it does not buy
Here's a failure mode that's easy to miss until it happens to you.
Your security team runs a scanner. A scanner's job is to probe for exactly the
things honeytokens are: identifiers that shouldn't resolve. Without an
exemption, its first sweep trips a decoy and revokes your own security team's
credential, and the failure looks like a successful detection, so nobody
investigates.
So recognition is allowlisted for scanner identities. Verified on a real run: 21
decoy requests, 0 recognised, 0 honeytoken hits, never revoked, while a
non-allowlisted client tripped 3 and was revoked immediately.
But, and this is the part I got wrong first, the exemption is much narrower
than its name suggests.
When I added a scanner agent and asserted it was never blocked, the assertion
failed. Twice. The scanner was still denied: first by the miss-ratio guardrail
when it probed aggressively, then, after I made its behaviour more realistic, by
novelty_run_length, because sweeping an ID range is the mimicry signature.
Both are correct. A scanner sweeping for non-existent records is genuinely
indistinguishable from an enumerator. There is no clever feature that separates
them, because there is no difference in the traffic.
So the demo now asserts what the allowlist actually promises, decoy probes are
not recognised and never cause a revoke, and prints a note when the scanner is
denied on other grounds:
✓ 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
The operational lesson is worth more than a green tick: allowlisting an identity
for honeytokens buys it no blanket immunity. Scanners need exempting at the
policy level too, or they need to not look like attackers.
Honeytokens as training labels
One last use, which is the most valuable and the least obvious.
If you're exporting a dataset to train a model on, you have a labelling problem:
in production you don't know which windows were attacks. Simulation labels are
available only in simulation and are useless for a model you intend to deploy.
Honeytoken hits are different in kind. They are a high-confidence positive label
that a real deployment also has, because nothing but an enumerator ever requests
an ID that was never issued to anyone.
So the exported dataset carries them as a separate column from the simulation
label:
window_start, …, predicted_tier, ml_anomaly, label, honeytoken_positive
label is the simulation's ground truth. honeytoken_positive is the one you'd
actually have. A model trained on the second is a model you could ship.
That reframes what honeytokens are for. They're not only a tripwire, they're the
one source of ground truth an anomaly detector in production can get for free.
Next: enforcement, a six-rung graduated ladder where every rung has to be
invisible to the client it's applied to, and the discovery that two of them were
doing nothing at all.
Top comments (0)