TOTP codes on your desktop, so you stop reaching for your phone.
A native macOS app in Rust: eframe for the picker window, objc2 and
AppKit for the Dock and activation behaviour, security-framework for the
login keychain, and nokhwa for the webcam importer. RFC 6238 and RFC 4226
are implemented directly and checked against the published test vectors.
Two sections here are about things that failed silently and took real work to diagnose, and they are the honest part of the project: why this is not a menu bar app, and what enrolling a second device costs you in security.
Click the Dock icon, or press control-option-command-M from anywhere. Type a few letters, press Enter, and the code is on your clipboard. Secrets live in the macOS login keychain and are never written to disk.
scripts/install.sh
Builds ~/Applications/mfa.app, symlinks the mfa command into
~/.local/bin, and registers a LaunchAgent so it starts at login. Because the
app runs all the time, its Dock icon is always there.
Add --pin-dock to also pin it permanently, so the icon keeps its place when
the app is not running. That one rewrites your Dock layout and restarts the
Dock, which is why it is opt in.
scripts/install.sh --uninstall reverses all of it.
Three ways in, depending on what the site gives you.
Paste the secret. Most sites show a "can't scan the code?" text secret next to the QR. Spaces, hyphens and lowercase are all fine.
mfa add gmail
Read a QR screenshot. Screenshot the QR with cmd-shift-4, then:
mfa add --qr ~/Desktop/qr.png
Paste an otpauth:// URI, if you already have one:
mfa add gmail --uri 'otpauth://totp/...'
Google Authenticator will not export anything but a QR code, so the shortest
path is to hold the phone up to the camera. Open the picker (Dock icon, or
control-option-command-M) and click Scan with camera. From a terminal,
mfa scan does the same thing.
Import file... in the same window opens a file picker for QR screenshots and export files, and you can drag images straight onto the window.
- In Google Authenticator: menu, then Transfer accounts, then Export accounts
- Pick the accounts and continue, which shows a QR code
- Hold the phone screen up to the webcam until the accounts appear in the list
- Save to keychain
One QR code carries up to ten accounts, so a normal setup is a single scan. If Google Authenticator splits the export across several codes, keep scanning: the window accumulates accounts across every code it reads and only writes to the keychain when you save. Codes already read are ignored, so a code lingering in frame does not add anything twice.
macOS asks for camera access the first time. If it was declined, the window offers an Open Camera settings button and a Try again button, so the switch can be flipped without restarting anything.
Two things about camera access are worth knowing, because both fail silently.
The app needs the com.apple.security.device.camera entitlement, since the
hardened runtime otherwise denies the camera with no prompt and no error. And
macOS attributes the request to the "responsible process", which for a binary
run by a shell is the terminal, not this app; mfa scan therefore relaunches
itself through open so that it is responsible for its own request. Without
either piece the camera opens successfully and then delivers no frames for
ever, which looks exactly like broken hardware.
mfa import <file-or-screenshot>
Recognized automatically:
| Source | What to hand it |
|---|---|
| Google Authenticator | Screenshot of the "Transfer accounts" QR |
| Aegis | Unencrypted JSON export |
| 2FAS | JSON export |
| Anything else | Text file of otpauth:// URIs, one per line |
Images can be PNG, JPEG, GIF, BMP, TIFF, WebP or HEIC. HEIC matters because a
photo taken on an iPhone is HEIC, and that is what you get from pointing one
phone at another; it is transcoded with sips before decoding.
An encrypted Aegis export cannot be read. Re-export with encryption turned off, import it, then delete the plaintext file.
Counter-based (HOTP) and Steam accounts are skipped, and named in the output.
Click the Dock icon or press control-option-command-M. Then:
| Key | What it does |
|---|---|
| type | filters accounts |
| up, down | move the selection |
| Enter | copy the selected code and close |
| Esc | close |
| the hotkey again | close |
It also closes when it loses focus, so clicking away is safe. On close the app steps aside and hands focus back to whatever you were typing in, so the code is ready to paste and the next Dock click counts as a fresh activation.
To change the combination, set MFA_HOTKEY and restart:
MFA_HOTKEY='cmd+shift+backslash' mfa
Modifiers are ctrl, alt (or opt), cmd, shift, joined with +. At
least one modifier is required, because a bare key would fire while you type.
To make a change stick, edit the ProgramArguments in
~/Library/LaunchAgents/com.brahy.mfa.plist to set the variable, or re-run the
installer after editing DEFAULT_HOTKEY in src/picker.rs.
The first version of this put the codes in a menu bar dropdown. On a 14 inch MacBook Pro that does not work, and the reason is worth recording.
The status item was created successfully every time. It just never appeared. Reading the window server's list of menu bar windows explains it: Control Center alone owns 18 items running from x=701 to x=1514 on a screen 1512 points wide. The bar is already overflowing past the right edge, so a new status item is given zero width and no window at all. Nothing about the icon or the title changes that.
So the Dock icon and the hotkey are not conveniences layered on top of the menu
bar. They are the interface. mfa tray still starts the menu bar version if you
want it on a machine with room to spare, and mfa list --codes always works in
a terminal.
mfa # start the picker (what the LaunchAgent runs)
mfa scan # read transfer QR codes with the webcam
mfa list # what is enrolled
mfa list --codes # with live codes and the countdown
mfa code gmail # print one code and copy it to the clipboard
mfa remove gmail # forget an account
mfa export --yes # print every secret as a URI, for a backup
mfa tray # menu bar version, if your menu bar has room
code and remove match loosely: enough of the name to be unambiguous is
enough. mfa code git finds github.
The picker re-reads the vault every time it opens, so an account added from the terminal is there immediately.
Your phone was a second device. This is not. A compromised Mac now yields both your password manager and your TOTP seeds, where before it yielded one of them.
The keychain narrows the window rather than closing it: secrets are encrypted at rest, unlocked with your login, and never touch disk in plaintext. Any process running as you can still read them, which is the same footing as your browser's saved passwords.
That trade is worth making for routine logins and not worth making for the accounts that would hurt most. Keep root, banking, and domain registrars on the phone.
Enrolling here does not remove the account from your phone, and a TOTP secret works in as many apps as you enroll it in. Keeping both is the cheapest backup.
mfa export --yes prints every secret in plain text. It is a real backup and a
real liability, so treat the output like a password file: no shell history, no
unencrypted disk, no cloud sync.
| Path | What it holds |
|---|---|
src/totp.rs |
RFC 6238 / RFC 4226, verified against the spec's test vectors |
src/account.rs |
An account and its otpauth:// form |
src/store.rs |
The keychain-backed vault |
src/enroll.rs |
QR decoding and the import formats |
src/picker.rs |
The hotkey and the picker window |
src/scan.rs |
The webcam scanner |
src/tray.rs |
The menu bar version |
src/main.rs |
The CLI |
assets/icon.svg |
The app icon, as editable source |
src/bin/mkicon.rs |
Renders that SVG into assets/mfa.icns |
To change the icon, edit assets/icon.svg and re-render it:
cargo run --features mkicon --bin mkicon
cargo test
Codes are checked against every published RFC 6238 vector across SHA1, SHA256 and SHA512, and the QR path is tested by encoding real PNGs and reading them back.
Set MFA_DEBUG=1 to trace the window lifecycle on stderr, which is how the
menu bar problem above was found. MFA_DEBUG_SHOW=1 opens the picker window at
startup, so the window can be checked without synthesising a system-wide key
event.
MIT. See LICENSE.