Miles WardandClaude Opus 4.8 f33effe20a v0.1: self-hosted TOTP authenticator with live Discord delivery
Rust workspace serving a Google/Microsoft-Authenticator-style web UI, with
each account's rotating code mirrored into Discord as a message that
rewrites itself in place as the code rolls.

The live mechanism is webhook message editing: POST ?wait=true returns the
message id, PATCH .../messages/{id} rewrites it thereafter. Two approaches
were evaluated and rejected — an externally-hosted image URL is cached by
Discord's media proxy and, worse, must be anonymously reachable, which would
publish live codes on an unauthenticated endpoint; and an animated countdown
drifts out of phase because playback starts at decode time, not on a wall
clock. The countdown instead uses Discord's <t:unix:R> markup, which the
client re-renders every second for free.

Codes are also sent as text in a fenced block so they stay copyable, with
the grouped, readable form on a rendered WebP tile.

- core: RFC 6238 TOTP over HMAC, lenient otpauth:// parsing (issuer conflicts
  and non-canonical base32 both occur in the wild), XChaCha20-Poly1305 vault,
  Argon2id admin auth
- discord: webhook client with a serialising rate limiter (~2 req/s) so
  globally-aligned rollovers don't burst, and error classification that
  distinguishes a deleted message (repost) from a dead webhook (stop)
- render: SVG -> WebP tiles via resvg with embedded JetBrains Mono
- http-api: axum, session auth, QR decode with a rescale/contrast/rotation
  retry ladder
- webui: offline vanilla JS, Beautiful UI design language, light and dark

Ships as a 27 MB Alpine image (about 8 MB to pull); the binary is fully static musl with no
OpenSSL and no C dependencies, cross-compiled via cargo-zigbuild so building
linux/amd64 on Apple Silicon needs no QEMU.

101 tests, including the RFC 6238 vectors, QR round-trips, and the live push
cycle exercised against a mock Discord.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-28 16:06:08 -04:00

Discord Authenticator

A self-hosted TOTP authenticator whose codes appear in Discord as live messages that rewrite themselves as each code rolls.

The web UI is what you'd expect from Google or Microsoft Authenticator: a searchable list of accounts, each with its six digits and a countdown ring. The part that isn't ordinary is the delivery — if you have Cloudflare, Plex and Ubisoft in the vault, your Discord channel holds three messages, each one staying current on its own.

Written in Rust. One static binary in a 27 MB Alpine image (about 8 MB to pull), no database, no external services beyond Discord itself.


Contents


How the live delivery works

The obvious approaches don't survive contact with Discord, and it's worth saying why, because the design is a direct consequence.

A live-updating image URL doesn't work. Pointing an embed at an image your server renders on demand fails twice over. Discord fetches the URL once at post time and stores a snapshot; clients then render a cached copy from media.discordapp.net, so new bytes at the same URL are never seen. And more seriously, Discord's crawler must reach that URL anonymously — which would mean publishing your live TOTP codes on an unauthenticated public endpoint. That alone disqualifies it.

An animated image doesn't work either. Animation playback starts whenever the client decodes the file, not on a wall clock. A 30-second countdown baked into frames drifts out of phase with the real window and ends up confidently displaying the wrong time remaining — worse than showing nothing.

What does work is editing the message. Posting to a webhook with ?wait=true returns the created message's id; PATCH .../messages/{id} then rewrites that message in place, and every client sees the change at once. So each account owns one message for its lifetime, and the app rewrites it once per period.

Two details make this pleasant rather than merely functional:

  • The countdown costs nothing. Discord renders <t:1756402860:R> as "in 24 seconds" and re-renders it every second client-side. So the ticking countdown needs no requests at all — only the digits do.
  • The digits stay copyable. The code goes into a fenced code block, which Discord gives a copy button, containing the bare digits (482913, not 482 913) so what lands on your clipboard is directly pasteable. The human-readable grouped form lives on the rendered tile.

Each tile is a WebP rendered by the app — issuer, account, the code at a size you can read across a room, and a per-issuer accent colour that matches the row in the web UI.

Rate limiting

TOTP windows are globally aligned, so every account's code rolls at the same instant. Left alone, ten accounts would fire a ten-request burst into a bucket that allows roughly five per second. Every call is therefore serialised through a limiter that spaces requests about 500 ms apart, spreading ten edits across five seconds of a thirty-second window, and backing off on 429 using the retry_after from the response body.

Failures are classified rather than blindly retried, because Discord bans an IP after 10,000 4xx responses in ten minutes:

Discord says Meaning What happens
10008 Unknown Message You deleted the tile Post a fresh one automatically
10015 Unknown Webhook Webhook deleted Stop, and show you the reason
50027 Invalid token URL rotated Stop, and show you the reason
50013 Missing permission Lost channel access Stop, and show you the reason
429 Rate limited Back off, skip the stale edit

Late edits are dropped rather than queued — if an edit is overdue the code has already changed, so the current one is sent instead of replaying a backlog.


Security: read this before you use it

Anyone who can read that Discord channel can read your codes. That is most of what a second factor is for. This tool deliberately trades some of that security for convenience, and you should decide knowingly:

  • Use a private channel in a server only you are in.
  • Treat the webhook URL itself as a secret — it is an unauthenticated write capability for that channel.
  • Anything with access to your Discord account — another logged-in device, a session token, a member of that server — inherits your second factor.
  • Consider leaving push off for your highest-value accounts. It's a per-account toggle, so a mixed setup is fine.

What the app does hold up its end on:

  • TOTP secrets are encrypted at rest with XChaCha20-Poly1305; vault.json contains ciphertext only, and the metadata beside it is deliberately readable.
  • The admin password is hashed with Argon2id.
  • The vault key lives in vault.key (mode 0600), or set DA_MASTER_KEY to keep it off the data volume entirely — then a leaked copy of that volume is useless on its own.
  • Everything under /api/* requires a session. There is no public read path.
  • The UI is fully offline: no CDN, no remote fonts, no external requests. The only outbound traffic the app ever makes is to Discord.

Why the key can't just be your password: the app has to mint codes unattended to keep the tiles live, so nothing can require a human to type a passphrase after a restart. The key is therefore readable by the process at boot. That protects against a leaked backup of the vault file — the realistic homelab accident — but not against someone who can read the whole data directory. DA_MASTER_KEY closes that second gap if you need it closed.


Running it

Docker Compose

docker compose up -d

Then open http://<host>:8710/.

Unraid

Copy deploy/unraid/discord-authenticator.xml into /boot/config/plugins/dockerMan/templates-user/ and add the container from the Docker tab, or point the Repository field at:

gitea.milesward.dev/mward4/discord-authenticator:latest

Bridge networking is correct — this makes outbound HTTPS calls and serves one port. Map /var/lib/discord-authenticator to somewhere on appdata and back that path up: losing it loses every stored secret.

Building and publishing the image

GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh

Using it

First run asks for a username, a password, and (optionally) your Discord webhook URL. Get the webhook from Discord: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL. All three are changeable later from Settings.

Adding an account offers three routes:

  • Upload a QR code image — PNG, JPEG, WebP, GIF, BMP or TIFF. Screenshots, photos of a screen, tiny crops and rotated images all work; the decoder walks a ladder of rescale, contrast and rotation attempts before giving up. (HEIC is rejected with a note to screenshot instead — iPhone screenshots are PNG.)
  • Camera scanning, using the browser's native barcode detector where it exists and falling back to server-side decoding where it doesn't, which covers Safari and Firefox. Note that browsers only grant camera access over HTTPS or on localhost.
  • Paste an otpauth:// link, or type a secret by hand.

The parser is deliberately forgiving of real-world URIs: it accepts lowercase and =-padded secrets, and where the issuer in the path disagrees with the ?issuer= parameter — which Microsoft does — it takes the query parameter rather than refusing the account.

In the list, click any code to copy it. / focuses the search box. The gear on a row renames the account or pauses its Discord push; pausing removes its tile from the channel.


Configuration

All settings are environment variables (or CLI flags of the same name).

Variable Default Meaning
DA_HTTP_ADDR 0.0.0.0:8710 Listen address for UI and API
DA_WORK_DIR /var/lib/discord-authenticator Vault, key, settings, admin account
DA_MASTER_KEY (unset) Base64 of 32 bytes. Overrides vault.key; nothing is written to disk
DA_LOG info,da=info tracing-subscriber filter

Generate a master key with openssl rand -base64 32. Setting it after secrets exist makes them unreadable — export first if you're migrating.

Message style (Settings → Discord) chooses what each tile contains:

  • Embed — code as text only. One JSON edit per period, nothing uploaded. The lightest option by a wide margin.
  • Image — the rendered WebP tile only.
  • Both (default) — tile plus copyable text.

The image styles re-upload a tile on every roll, which is roughly 2,880 attachments per account per day. Harmless, but if you run many accounts and want the traffic minimal, Embed is the frugal choice.


Architecture

crates/
  core/                  TOTP, vault, crypto, settings, admin auth — no network
  render/                SVG → WebP tile rendering (resvg + embedded fonts)
  discord/               Webhook client, rate limiter, live-tile push engine
  webui/                 Embedded HTML/CSS/JS, compiled into the binary
  http-api/              axum router, sessions, QR decoding
  discord-authenticator/ The binary

core is deliberately network-free so the security-critical parts are testable without a socket. TOTP is implemented directly over HMAC (about thirty lines) rather than pulling a crate — the deciding factor was that real otpauth:// URIs need the lenient parsing described above, which the available crates reject, so the normalisation layer would have had to be written anyway.

Everything is pure Rust: no OpenSSL, no -sys crates, no C. TLS roots are compiled in via webpki-roots, which is why the runtime image needs no ca-certificates and the binary is genuinely standalone.

The UI follows Beautiful UI — OKLCH colour ramps, hairline-ring shadows, inset fields against raised buttons, Inter and JetBrains Mono. Light and dark both ship.

Testing

101 tests, including:

  • The RFC 6238 test vectors for SHA-1, SHA-256 and SHA-512.
  • Real QR round-trips — clean, 1-pixel-per-module, oversized, rotated, JPEG.
  • The live push cycle against a mock Discord: that the first call carries ?wait=true, that later passes PATCH the same message rather than posting again, that a deleted tile is reposted, that a dead webhook is surfaced instead of hammered, and that concurrent rollovers are paced apart.
cargo test --workspace
cargo clippy --workspace --all-targets

Development

cargo run -p discord-authenticator -- --work-dir ./data/work --http-addr 127.0.0.1:8710

Preview the Discord tile without a webhook:

cargo run -p da-render --example preview -- /tmp

Licence

MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License 1.1 (crates/render/assets/JetBrainsMono-OFL.txt).

S
Description
Self-hosted TOTP authenticator with live Discord delivery. Container-native Rust.
Readme
885 KiB
0 Stars 1 Watchers 0 Forks
2026-09-20 04:08:25 -04:00
Languages
Rust 71.7%
JavaScript 18.3%
CSS 7.3%
Shell 1.3%
Dockerfile 1.1%
Other 0.3%