ADR-027: Honeytoken strategy — where, how, what triggers the alert
Status: Accepted Date: 2026-04-21
Context
Honeytokens are fake credentials and fake resources planted in a system where legitimate users would never touch them. Any touch is, by construction, an attacker — either one probing for secrets or one mid-exfil.
The alert signal is unusually high-quality: near-zero false positives, binary semantics, immediate actionability. The cost to deploy is small (a file here, a DB row there, a route returning 404). The cost to miss is the usual security cost — a breach we only learn about via a customer.
Honeytokens become more valuable under certain threat classes this platform explicitly has:
- AI-agent compromise (a compromised agent reads secrets from disk and surfaces them — a planted fake AWS key gets used externally).
- Insider / contractor curiosity (someone with legitimate filesystem access dips into things they shouldn't — a planted DB row touched reveals the curiosity without requiring a full audit log).
- Post-breach dwell detection (an attacker has a foothold and is quietly enumerating — a planted endpoint hit is the foothold's tell).
The cost side: honeytokens require maintenance (rotation, distinction from real tokens, don't-break-CI wiring) and can create false positives if our own tooling accidentally touches them.
Decision
Ship three honeytokens in this tranche, one of each class: a fake AWS access key, a fake DB row, a fake internal endpoint. Each has its own alert rule (tripwire 14) and a shared runbook.
Shape:
14a — Fake AWS access key
- Where: a dotfile in the agent's workspace (
~/.aws/credentials) on the agent's VPS. The file exists solely as bait. - Value: a real AWS access key minted specifically for this purpose, in an isolated AWS account with zero attached permissions (it cannot do anything). AWS's own monitoring detects usage of this key and alerts via AWS SNS → webhook → our Grafana contact point.
- Why that shape: the alert is external (AWS sees the key used), which means it works even if our internal observability is compromised. The key being real but permissionless means it cannot be abused if extracted; the attack surface is only the AWS monitoring pipeline.
- Rotation: annually. Rotating any more often makes it suspicious; the attacker's reconnaissance may include "check when this file was last modified".
- False-positive case: our deploy pipeline reads the file to sanity-check its continued presence. AWS allowlists our deploy pipeline's source IP.
14b — Fake DB row
- Where: one row per product's
userstable withid='honeytoken_001',email='honeytoken+001@gyrum.ai',status='disabled'. An additional row in everyworkspacestable withid='honeytoken_ws_001'. No relationship to real data. - Trigger: any
SELECT,UPDATE, orDELETEtouching these IDs fires the alert. Detection is via service log lines — every service already logs query results and includes theuser_idin its access log. LogQL matches on the planted IDs. - Why that shape: the fake row is reached only if an attacker is running enumerating queries (e.g.
SELECT * FROM users WHERE email LIKE ...) or is directly querying the DB out-of-band. Both are high-signal. - Rotation: never; the IDs are baked in. If a service's logs ever legitimately mention the honeytoken ID (e.g. a SELECT * that returned it), the service is refactored to exclude them at query time — which itself is a good forcing function for per-service "exclude disabled users" logic.
- False-positive case: a developer writes a SELECT during local dev and the query hits the planted row. The tripwire fires. Accepted risk — the alert includes the source IP; a dev IP is a one-line dismissal. Over time, the rate of dev-originated fires is itself a metric (are we running queries against prod from dev?).
14c — Fake internal endpoint
- Where: every service mounts
/internal/debug/tokensthat responds with 404. Caddy allows the path to reach the service. - Trigger: the service's middleware records the hit (IP, timestamp, authentication state, request_id) at INFO level with a distinctive marker (
event=honeytoken_hit). LogQL alert fires on any log line with that marker. - Why that shape: the path is never linked, never referenced in JS, never in the sitemap. A visitor who hits it is probing, and the only way to discover it is either by wordlist guessing (a recon signal of its own) or by having seen it in a source code leak (the more alarming signal).
- Rotation: change the path name every six months. The change is a simple middleware rewire; the old path silently continues to exist and fire the same alert (backward-compat for "attackers with stale intelligence"). A second path sharing the signal is a feature.
- False-positive case: scanners on the public internet occasionally hit
/internal/debug/tokensas part of a wordlist scan. These are the same scanners tripwire 6 (404 scan) catches; the difference is honeytoken 14c fires on the specific path as a high-confidence signal of someone targeting an endpoint that doesn't appear in any wordlist we've seen.
What all three have in common
- Alert severity:
pagefor 14a and 14b (an attacker using a real AWS key or touching a DB row is active exploitation).criticalfor 14c (recon-level signal; can be batched with other tripwires). - Runbook:
runbooks/honeytoken-access.md, shared across all three sub-tripwires. - Placement in tripwire doc: 14a/b/c.
Consequences
- We get early warning on agent compromise. If the agent is tricked into surfacing secrets from its workspace, the fake AWS key is among the first it would surface (it's in the canonical
~/.aws/credentialspath). AWS's monitoring fires before any real credential has been abused. - We get early warning on DB-level attacks. An attacker with DB access reaches for "list all users" before they reach for specific IDs. The honeytoken row is a trip-wire on that first enumerating query.
- We get a signal on wordlist-plus-one probes. Scanners that read our public JavaScript or leaked-source and try the "insider" path get caught by tripwire 14c.
- Maintenance is ongoing. The AWS key requires annual rotation (calendar reminder, not automated). The DB rows require not-breaking existing queries on init. The internal path requires middleware upkeep. All three must not accidentally be exercised by our own code — a CI test runs against a known-bad path to confirm no internal consumer hits them.
- Honeytokens age poorly in public. An attacker who finds this ADR learns the exact IDs, paths, and patterns. Mitigation is in place: the AWS key's value is permissionless (knowing it doesn't help); the DB row's IDs are known (the alert still fires on touch); the internal path's rotation cadence reduces the half-life. Obfuscation-via-ADR-redaction was considered and rejected: the decision's review value outweighs the obfuscation benefit, and a determined attacker with repo-read already has far more than this ADR.
- We pay for the detection; we don't gain prevention. Honeytokens detect compromise, they don't prevent it. The value depends on our response time: the runbook (
runbooks/honeytoken-access.md) has the procedure, and the detective control is coupled to the containment procedure in security-runbook §phase-2-contain. - Failed honeytokens are worse than none. A honeytoken that silently stops working, or fires noisily without a real attacker, erodes trust in the signal. The monthly alert review verifies each honeytoken's wiring with a synthetic trigger.
Alternatives considered
- No honeytokens. Rejected: the threat-model row for "agent compromise" has no other high-confidence detective control. The SOC-2-style "review all access logs" is too expensive for the team size; honeytokens are the cheap proxy.
- Real-but-low-value-credentials (canary tokens on the network-edge side — e.g. a real MySQL creds for a honeypot MySQL). Rejected as too much infrastructure for the marginal signal. The three types above cover filesystem, DB, and HTTP — the three surfaces an attacker is likely to probe. Network-level honeypots are Tier-3 territory.
- Canary-token service (thinkst / honeytokens.org). A SaaS provides signed honeytokens that phone home when used. Rejected: dependency on an external service that is itself a target for attackers motivated to disable. Also, a phone-home signal is one alert path; our internal alerting pipeline is more integrated.
- One honeytoken for all products (shared fake user). Rejected: per-product isolation means a touch in one product is a specific signal. A shared token shared across products would only tell us "something touched the shared row"; we'd spend triage time figuring out which product.
- Obfuscate the honeytoken names/values in this ADR. Rejected: the ADR is readable by every engineer; obfuscation hurts our own reviewers more than it hurts an attacker with repo access. Attackers with repo access are a separate threat class with dedicated controls.
Supersedes: none Superseded by: