A self-hosted TOTP authenticator whose codes appear in Discord, Slack or Telegram 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 behind recognisable service marks, each code covered until you reveal it, with a countdown ring while it shows. The part that isn't ordinary is the delivery — if you have Cloudflare, Plex and Ubisoft in the vault, your chosen 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 the chat platform itself. OpenMFA grew out of [Discord Authenticator](https://gitea.milesward.dev/mward4/Discord-Authenticator), which remains the Discord-only sibling; this is the platform-neutral project. --- ## Contents - [Which platform, and what each one needs](#which-platform-and-what-each-one-needs) - [How the live delivery works](#how-the-live-delivery-works) - [Security: read this before you use it](#security-read-this-before-you-use-it) - [Running it](#running-it) - [Using it](#using-it) - [Configuration](#configuration) - [Architecture](#architecture) - [Roadmap](#roadmap) --- ## Which platform, and what each one needs The three platforms authenticate differently, and the reason is the same in each case: OpenMFA has to be allowed to *edit* a message it posted earlier. | Platform | You provide | Why that, and not something simpler | |---|---|---| | **Discord** | An incoming-webhook URL | Discord webhooks can edit and delete their own messages, so no bot is needed. | | **Slack** | A bot token (`xoxb-…`, scope `chat:write`) and a channel ID | Slack incoming webhooks can post but **cannot edit**. Only a bot using `chat.postMessage` / `chat.update` can keep a tile live. | | **Telegram** | A bot token from @BotFather and a chat ID | Telegram has no webhook concept; everything is a bot. `sendMessage` / `editMessageText`. | Setup walkthroughs are built into the app's Delivery settings, but in brief: - **Discord** — Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL. - **Slack** — `api.slack.com/apps` → Create New App → OAuth & Permissions → add the `chat:write` bot scope → Install to Workspace → copy the *Bot User OAuth Token*. Then in the target channel run `/invite @YourBot`, and copy the channel's ID (click the channel name; it's at the bottom of the details pane). The **Check token** button confirms which workspace the token belongs to before you save. - **Telegram** — message **@BotFather**, send `/newbot`, copy the token. Then send `/start` to your new bot (or add it to a group and say anything) and click **Detect chat** — the app lists every chat the bot has heard from so you never have to find a chat ID by hand. ### Practical account limits per platform Each account costs two edits a minute (one per 30-second roll). Rate limits are per-minute, so they cap how many accounts one destination can carry: | Platform | Limit that bites | Comfortable ceiling | |---|---|---| | Discord | ~5 req/s per webhook; ~30/min reported | ~10–12 accounts per webhook | | Slack | `chat.update` is Tier 3, ~50/min per workspace | ~20 accounts | | Telegram, private chat | 1 msg/s per chat | ~30 accounts | | Telegram, group | 20 msg/min per group | **~10 accounts** | Past those, split across several webhooks/channels, or pause push on the accounts you don't need live. --- ## How the live delivery works The obvious approaches don't survive contact with any of these platforms, and it's worth saying why, because the design is a direct consequence. **A live-updating image URL doesn't work.** Pointing a message at an image your server renders on demand fails twice over. The platform fetches the URL once at post time and serves a cached copy, so new bytes at the same URL are never seen. And more seriously, its 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.** 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 confidently displays the wrong time remaining — worse than showing nothing. **What does work is editing the message.** Post once, remember the id, rewrite it every period. Every client sees the change at once. So each account owns one message for its lifetime, and the app rewrites it once per period — on Discord via `PATCH …/messages/{id}`, on Slack via `chat.update`, on Telegram via `editMessageText`. Two details make this pleasant rather than merely functional: - **The digits stay copyable.** The code goes into a code span containing the bare digits (`482913`, not `482 913`) so what lands on your clipboard is directly pasteable. Discord and Slack render a copy button on fenced code; Telegram's `` is tap-to-copy on mobile.
- **A rollover time, as live as the platform allows.** Discord's ``
ticks in the client every second for free. Slack's `` renders once in
the viewer's local time. Telegram gets a plain UTC clock. In every case the
digits themselves are refreshed by the edit at the boundary — the countdown
is a courtesy, not the mechanism.
On Discord each message can also carry a rendered WebP tile — issuer, account,
the code at a size you can read across a room, with a per-issuer accent colour
matching the row in the web UI. Slack and Telegram receive text; see
[Roadmap](#roadmap).
### Rate limiting and failure handling
TOTP windows are globally aligned, so every account's code rolls at the same
instant and N accounts would fire an N-request burst. Every call is serialised
through a per-platform governor — 500 ms apart on Discord, 1 s on Slack and
Telegram — and backs off on `429` using whatever retry hint the platform
returns.
Failures are classified rather than blindly retried, because every platform
eventually bans a client that hammers 4xx responses:
| Meaning | Discord | Slack | Telegram | What happens |
|---|---|---|---|---|
| Message was deleted | code `10008` | `message_not_found`, `cant_update_message` | "message to edit not found" | Post a fresh one automatically |
| Credential dead | `10015`, `50027` | `invalid_auth`, `token_revoked` | 401 / 404 | Stop, and show you the reason |
| Channel gone / no access | `10003`, `50013` | `channel_not_found`, `not_in_channel` | "chat not found", "bot was blocked" | Stop, and show you the reason |
| Rate limited | 429 | 429 | 429 | Back off, skip the stale edit |
| Content identical | — | — | "message is not modified" | Treated as success |
---
## Security: read this before you use it
**Anyone who can read that 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 or workspace only you are in**.
- Treat the **webhook URL or bot token itself as a secret** — it is a write
capability for that channel.
- Anything with access to your Discord, Slack or Telegram 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 `OPENMFA_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.
- Codes are **covered until revealed**, per session, and every reveal is
logged with the caller's address — a screen left unattended shows dots, and
a borrowed session leaves a trace.
- The UI is fully offline: no CDN, no remote fonts, no external requests. The
only outbound traffic the app ever makes is to the chat platform you chose.
**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. `OPENMFA_MASTER_KEY` closes that second gap if you need it closed.
---
## Running it
### Docker Compose
```bash
docker compose up -d
```
Then open `http://:8720/`.
### Unraid
Copy `deploy/unraid/openmfa.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/openmfa:latest
```
Bridge networking is correct — this makes outbound HTTPS calls and serves one
port. Map `/var/lib/openmfa` to somewhere on `appdata` and **back that path
up**: losing it loses every stored secret.
### Building and publishing the image
```bash
GITEA_TOKEN= ./scripts/build-and-publish-unraid.sh
```
---
## Using it
**First run** asks for a username and password, then offers a three-way choice
of Discord, Slack or Telegram — optional, so you can wire a platform later from
Settings → Delivery. Switching platforms afterwards keeps the other platforms'
details, so you can move back and forth without re-pasting tokens.
**Reading a code.** Codes are covered by default — six dots and an eye. Click
to reveal one: the digits appear with their countdown ring and a copy button,
stay for a minute (long enough to survive a rollover), then go back under
cover on their own. A reveal is a request to the server, which is what makes
the log below trustworthy: the browser never holds a code it hasn't asked for
by name, so "covered" is not a CSS trick.
**Service marks.** An `otpauth://` link carries no artwork — only the issuer,
the label and the secret — so OpenMFA ships a pack of about 175 marks
([Simple Icons](https://simpleicons.org/), plus a few Font Awesome brands for
the ones Simple Icons no longer carries: Amazon, AWS, Microsoft, LinkedIn,
Slack, Xbox, Salesforce, Yahoo) and matches on the issuer name — the whole
name first, then shorter prefixes ("Google Workspace" → Google), then the
label's mail domain (`me@gmail.com` → Gmail). Unmatched accounts keep the
initial tile. `scripts/fetch-icons.py` regenerates the pack; add a slug or an
alias there.
**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.)
If the image holds several QR codes, all of them are read.
- **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.
### Importing from Google Authenticator
Google Authenticator's **Transfer accounts → Export accounts** produces a QR
code holding *many* accounts at once — an `otpauth-migration://` link wrapping
a base64 protobuf. Upload or paste it and you get a checklist of everything
inside, with each entry's algorithm, digit count and period, and an **Add N
accounts** button.
- **Large exports are split across several QR codes.** Each reports its
position, so the UI says "Part 1 of 2" and prompts you for the rest.
- **Re-importing is safe.** Anything already in your vault is detected and
pre-unticked. Duplicates match on the secret *and* the naming, so deliberately
keeping one secret under two labels still works.
- **Entries that can't be imported are named, not silently dropped** — a
counter-based (HOTP) account or an MD5 one is listed with the reason.
- **Secrets never reach the browser.** The preview returns names and parameters
only; on import the client sends back the link plus the indices it selected,
and the server re-parses it.
Two traps this format sets, both of which silently produce *plausible but
wrong* codes: the protobuf `secret` field is **raw bytes**, not base32, and the
payload carries **no period field** — every Google export is 30-second TOTP.
The `otpauth://` 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
pencil on a row opens **Edit account**: the *Name* shows in the list and on the
tile; the *Account* field (usually the username or email) is left alone unless
you change it. A rename reaches the chat within a second or two — the push loop
clears its "already sent" marker so it doesn't wait for the code to roll.
### The log
**Settings → Log** is an audit trail in the style of Daito: one line per event
— *admin revealed GitHub · miles@example.com* — with how long ago, who, and
from which address underneath. It records what the server did, never what the
browser claims: sign-ins (and failed attempts, under the name that was tried),
every reveal, accounts added, renamed and removed, delivery and credential
changes, test messages. Entries live in `audit.jsonl` beside the vault,
owner-only; the newest 5,000 are kept.
The address is the TCP peer unless a reverse proxy forwards the real one in
`X-Forwarded-For` or `X-Real-IP`. On Unraid in **bridge** networking the peer
is Docker's gateway (`172.17.0.1`) for every LAN client — put the app behind
Nginx Proxy Manager, SWAG or a Cloudflare tunnel, or run it on `host` or a
custom `br0` network, to see real addresses.
---
## Configuration
All settings are environment variables (or CLI flags of the same name).
| Variable | Default | Meaning |
|---|---|---|
| `OPENMFA_HTTP_ADDR` | `0.0.0.0:8720` | Listen address for UI and API |
| `OPENMFA_WORK_DIR` | `/var/lib/openmfa` | Vault, key, settings, admin account |
| `OPENMFA_MASTER_KEY` | *(unset)* | Base64 of 32 bytes. Overrides `vault.key`; nothing is written to disk |
| `OPENMFA_LOG` | `info,openmfa=info` | `tracing-subscriber` filter |
| `OPENMFA_SLACK_API_BASE` | `https://slack.com/api` | Override for proxies |
| `OPENMFA_TELEGRAM_API_BASE` | `https://api.telegram.org` | Override for a self-hosted Bot API server |
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 → Delivery, Discord only) chooses what each tile
contains: **Text** (one JSON edit per period, nothing uploaded — the lightest),
**Image** (the WebP tile), or **Both** (default). The image styles re-upload a
tile on every roll — roughly 2,880 attachments per account per day. Harmless,
but Text is the frugal choice for many accounts.
---
## Architecture
```
crates/
core/ TOTP, vault, crypto, settings (incl. provider config), admin auth,
the audit log, Google Authenticator export parsing — no network
render/ SVG → WebP tile rendering (resvg + embedded JetBrains Mono)
delivery/ Discord, Slack and Telegram clients behind one Sink; the push engine
webui/ Embedded HTML/CSS/JS and the service-icon pack, compiled into the binary
http-api/ axum router, sessions and reveal windows, QR decoding, the log API
openmfa/ The binary
```
`core` is deliberately network-free so the security-critical parts are testable
without a socket. The delivery engine is platform-blind: it resolves a `Sink`
from settings each pass and only ever calls `create`, `edit` and `delete`; each
platform client owns its own rate-limit governor and its own error
classification.
Everything is pure Rust: no OpenSSL, no `-sys` crates, no C. TLS roots are
compiled in via `webpki-roots`, so the runtime image needs no `ca-certificates`
and the binary is genuinely standalone.
The UI follows [Beautiful UI](https://www.beautifului.dev/) — OKLCH colour
ramps, hairline-ring shadows, inset fields against raised buttons, Inter and
JetBrains Mono. The accent is monochrome in the vein of [Daito](https://daito.io):
ink in light mode, near-white in dark, so the only colour on screen is status
(live, expiring, error) and the service marks. Light and dark both ship.
### Testing
`cargo test --workspace` runs the suite, including the **RFC 6238 vectors**,
real **QR round-trips**, **Google export decoding** from protobuf wire bytes,
and the **live push cycle against mock Discord, Slack and Telegram servers**
that speak each platform's real error conventions — Slack's `ok:false` on HTTP
200, Telegram's `description` strings, Discord's JSON error codes — asserting
that tiles are edited in place rather than re-posted, that deleted tiles are
recovered, that dead credentials are surfaced instead of hammered, and that
renames refresh without waiting for a rollover. The router is exercised end to
end as well: codes stay covered until revealed, a reveal is scoped to the
session that asked, and the log carries the forwarded client address.
---
## Roadmap
- **v0.2 — Microsoft Teams.** Harder than the other three: Teams' incoming
webhooks were retired in May 2026 and their replacement (Power Automate
Workflows) can post but not edit, so live tiles need a Bot Framework bot.
See [`docs/TEAMS.md`](docs/TEAMS.md) for the exploration.
- Image tiles on Telegram via `editMessageMedia`.
- A Slack slash command (`/openmfa `) as a pull alternative to live
tiles, in the style of Daito — for teams that would rather fetch a code than
have it on screen.
- Email → chat forwarding for services that send codes by email.
## Changelog
- **v0.2** — Covered codes, service marks, and a log. Codes show as dots until
revealed (server-side, per session, for a minute); the account list shows
each service's mark instead of an initial, from a bundled pack of ~175;
**Settings → Log** records reveals, sign-ins and every change with who and
from which address; the setup and delivery pickers are plain text.
- **v0.1.1** — Monochrome UI. The accent is ink rather than blue across the
primary buttons, the floating **Add an account** button, the empty-state
shield, the settings tabs and the focus glow on inputs; the settings tabs
are spaced so their hairline rings no longer touch.
- **v0.1** — First release: live TOTP tiles to Discord, Slack or Telegram, a
three-way provider picker at setup, Google Authenticator export import,
QR upload / camera / paste, and the Unraid template.
## Licence
MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License
1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`). The service-icon pack in
`crates/webui/src/icons.js` is generated from [Simple Icons](https://simpleicons.org/)
(CC0 1.0) and [Font Awesome Free](https://fontawesome.com/license/free) brand
icons (CC BY 4.0); the marks themselves remain trademarks of their owners.