A video on building a 2FA desktop client in Go surfaced on Lobsters recently, and it caught my attention because it sits at an intersection the Go community doesn’t talk about much: native desktop apps. Most 2FA content either covers mobile libraries or stops at a CLI tool that reads from a flat file. A real desktop client, one you’d actually use daily, requires solving several independent problems at once. The cryptographic layer is the easy part. The hard parts are the GUI, secure secret storage, and QR code import, and each one has real trade-offs in Go’s ecosystem.
The Algorithm Itself
TOTP is defined in RFC 6238, which extends the HOTP counter-based scheme from RFC 4226. The core computation is straightforward: TOTP(K, T) = HOTP(K, floor((now - T0) / X)), where K is the shared secret, T0 is the Unix epoch, and X is the time step (30 seconds by default). HOTP itself is a truncated HMAC-SHA1 over an 8-byte big-endian counter, reduced to 6 decimal digits via mod 10^6.
For SHA-256 and SHA-512 variants (which some services use), the key length recommendations differ: 20 bytes for SHA-1, 32 for SHA-256, 64 for SHA-512. Most real-world services still use SHA-1, but a robust implementation should handle all three.
In Go, github.com/pquerna/otp is the de facto library for this. It handles TOTP generation, validation, key generation, and URI parsing. Generating a code for the current time is one call:
code, err := totp.GenerateCode(secret, time.Now())
Parsing an otpauth:// URI from a QR code, which is what you’ll need when importing accounts, is equally direct:
key, err := otp.NewKeyFromURL("otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")
fmt.Println(key.Secret(), key.Period(), key.Algorithm())
The library also handles clock skew by accepting codes from adjacent time windows, which you configure via totp.ValidateOpts{Skew: 1}. One window of skew (±30 seconds) is the standard recommendation.
The otpauth:// URI Format
Before reaching for any library, it’s worth understanding what otpauth:// actually is: an informal spec that originated with Google Authenticator, documented on a Google Code wiki page that became the de facto standard. It is not an RFC. That means implementations vary, and your parser needs to be lenient.
The format is: otpauth://TYPE/LABEL?PARAMETERS. The label is often issuer:account, URL-encoded. The issuer parameter should match the label prefix when both are present, but many services get this wrong. The secret parameter is Base32-encoded without padding. If you’re parsing this yourself rather than delegating to pquerna/otp, Go’s net/url package handles the structural parsing, and encoding/base32 with StdEncoding.WithPadding(base32.NoPadding) handles the secret decoding.
Choosing a GUI Framework
This is where projects diverge significantly, and no choice is clearly superior.
Fyne is the most commonly used toolkit for Go desktop apps. It has a retained-mode widget model, built-in theming, system tray support, and a full widget library. For a 2FA client it gives you widget.Label for the code display, widget.ProgressBar for the countdown timer, and widget.List for the accounts list, all without writing layout code from scratch. Binary sizes run around 20 MB with static linking. Fyne’s abstraction layer means the app looks similar across platforms but doesn’t feel native on any of them.
Gio is an immediate-mode framework with GPU-accelerated rendering. It produces smaller binaries (5-8 MB) and offers better performance, but there is no widget library in the traditional sense. You compose layouts from primitives. For someone already comfortable with immediate-mode UI (as used in game engines or tools like Dear ImGui), this is a fine choice. For a weekend project it adds significant friction.
Wails takes a different approach entirely: you write the UI in HTML, CSS, and JavaScript using any frontend framework you like, and Go handles the application logic. The bridge between the two is generated bindings. This produces the most visually polished result with the least effort on the UI side, at the cost of a WebView dependency and slightly larger distribution packages.
For a 2FA client specifically, Fyne is the pragmatic choice if you want a single Go codebase. Wails makes sense if your team is more comfortable with web frontends.
The Live Refresh Pattern
A TOTP client needs to update its displayed code every second and show a countdown for the remaining validity window. In Go this maps naturally to a ticker goroutine:
ticker := time.NewTicker(time.Second)
go func() {
for t := range ticker.C {
code, _ := totp.GenerateCode(secret, t)
// Update label on the UI thread
codeLabel.SetText(code)
remaining := 30 - (t.Unix() % 30)
progressBar.SetValue(float64(remaining) / 30.0)
}
}()
Fyne’s SetText and SetValue are safe to call from goroutines, so this pattern works without additional synchronization. With Gio you’d send a state update through a channel and trigger a re-render.
Secret Storage: The Part Most Tutorials Skip
This is the most consequential architectural decision in the whole project, and it gets skipped in most blog posts. Storing TOTP seeds in a plaintext JSON file or SQLite database is a meaningful security regression from keeping them on a phone with full-disk encryption and biometric lock.
The right approach is OS-provided secret storage: Keychain on macOS, Windows Credential Manager, and the Secret Service API (via libsecret/D-Bus) on Linux. github.com/zalando/go-keyring provides a unified interface across all three:
// Store a secret
err := keyring.Set("my-2fa-app", "alice@example.com", base32Secret)
// Retrieve it
secret, err := keyring.Get("my-2fa-app", "alice@example.com")
github.com/99designs/keyring is a more configurable alternative that supports additional backends including an AES-GCM encrypted file for environments where no OS keychain is available. It also handles the multi-backend fallback logic you need for Linux, where libsecret may not be present.
Beyond storage backend selection, there are several smaller practices worth enforcing. Zero out secret byte slices after use rather than waiting for GC. Avoid logging anything that touches the secret value. On Linux, note that secrets in the Secret Service are accessible to any process running as the same user, which is a meaningful threat model consideration worth documenting for users.
QR Code Import
Importing accounts by scanning a QR code is table stakes for a 2FA client. On desktop you typically offer two flows: import from an image file the user selects, or capture a screen region containing a QR code.
For decoding QR codes from images, github.com/makiuchi-d/gozxing is a pure-Go port of the ZXing library and is the most capable option:
file, _ := os.Open("qr.png")
img, _, _ := image.Decode(file)
bmp, _ := gozxing.NewBinaryBitmapFromImage(img)
result, err := qrcode.NewQRCodeReader().Decode(bmp, nil)
if err == nil {
key, _ := otp.NewKeyFromURL(result.GetText())
}
For screen capture, github.com/kbinani/screenshot provides cross-platform display capture. Combining it with the gozxing decoder gives you a “capture from screen” import flow.
Generating QR codes for export goes the other direction. pquerna/otp can produce a QR image directly via key.Image(200, 200), which returns a standard image.Image you can encode to PNG and display in your GUI.
How This Compares in Other Languages
For context: Python has pyotp and tkinter or PyQt; the TOTP layer is equally simple but secret storage requires reaching for keyring (which also wraps the OS backends). Rust has totp-rs and either iced or egui for GUI, with keyring-rs for storage. The ergonomics are similar in all three languages for the cryptographic layer. The difference is in the GUI ecosystem maturity: Qt bindings (Python or Rust) produce more native-looking apps than Fyne, but require a C++ runtime dependency that Go’s static linking avoids.
The Go version wins on distribution simplicity. A single static binary with no external runtime dependencies is a meaningful advantage for a desktop security tool, where users are right to be suspicious of complex installers.
Where to Go From Here
The totp-cli project is a well-structured Go reference: it uses pquerna/otp, 99designs/keyring, and Cobra for the CLI interface. For a desktop app, Aegis on Android is worth studying for its vault format specification, which describes a clean approach to encrypting backup files with AES-256-GCM and a scrypt-derived key, independently of the OS keychain.
The video on Lobsters is a solid starting point for seeing the pieces assembled. The implementation decisions above are what you’ll need to work through once the basics are running.