Setup
Three parties, no shared session
The apoptotic Discord bot has an opt-in alt-account detection feature. A
moderator runs /verify @user; the user clicks a link; the result —
"two of these accounts share an IP" — gets recorded against the guild. Three
parties are involved:
- The bot, which knows the Discord user and the guild but does not see the user's HTTP traffic.
- The user's browser, which has the IP but no Discord context.
- The website at apoptoses.com, which sees the request but does not, on its own, know which Discord user it represents.
None of these parties shares a session. The website is a separate origin from
Discord; the bot has no cookie store; the browser is asked to make exactly one
request and then close the tab.
Mechanism
What HMAC buys you
HMAC is a small, well-understood primitive: a secret-keyed hash function that
lets one party prove to another that a message was issued by someone holding
the key, without sharing the key over the wire. It does not encrypt anything —
every claim in the message is in the clear. It only proves integrity
and provenance.
For a verification token that's exactly the right shape. The bot generates a
payload like {user_id, guild_id, expires_at, nonce}, signs it with
a secret only the bot and the website share, and gives the user a URL with the
payload and signature in the query string. The website, on receiving the URL,
re-computes the HMAC and compares. If it matches, the website knows two things:
- The bot — the only other holder of the secret — issued this token.
- None of the fields have been tampered with since.
Token shape
Single-use, time-limited
HMAC by itself does not prevent replay. A correctly signed token is
correctly signed forever. Two layers fix that:
- Expiry. The payload includes
expires_at. The website rejects anything past it. A short window (minutes, not hours) keeps the attack surface tight.
- Single use. The website remembers nonces it has already accepted, for at least the lifetime of the token. A second visit to the same URL is rejected.
The combination is "the bot trusts the website to honor the rules; the website
trusts the bot to issue rules it can honor". That's the entire trust model.
Privacy
Salt-per-guild and what gets stored
The actual goal — alt detection — only needs to know whether two users in the
same guild share an IP. So the website salts the SHA-256 of the IP
with the guild ID before hashing. Same IP, different guilds → different
hashes. There is no cross-guild correlation by construction.
What gets stored, and where:
- The bot stores: the resulting hash, the user ID, the guild ID, a timestamp.
- The website stores: nothing. The hash is computed and forwarded; the raw IP is dropped from the request that produced it; the nonce is held only long enough to prevent replay.
The full data-handling description, including the salt-per-guild and
no-raw-IP-stored guarantees, is in the apex
Privacy Policy.
Failure
Failure modes I had to think about
- The user clicks the link from a different IP than they normally use. That just means a single sample is taken; the alt detection is a best-effort heuristic, not a polygraph.
- The user shares the link publicly. Single-use + expiry + the fact that the link does nothing useful to a third party (no UI, just a hash) limit the damage to "the legitimate user can't use the link, file a new one".
- The bot's signing secret leaks. Then someone could mint forged tokens that would tell the website to record alt links between arbitrary users. The mitigation is the usual — secret rotation — and the realization that compared to other things the bot has access to, this is not the worst leak.
Practice
What this looks like in code
The bot side is a few dozen lines: build the payload, base64 it, HMAC it, append
the signature, paste together a URL. The website side is a few dozen more:
parse the URL, verify the HMAC in constant time, check the expiry, check the
nonce against a recent-list, hash the IP with the guild salt, POST the hash
back to the bot's HTTP callback server.
The implementations live in dc_bot/src/main.rs and the apex
website's src/handlers.rs. Both sides are small and boring on
purpose; the security depends on the primitives being used correctly, not on
the surrounding code being clever.
Related
Related reading