· 6 min read ·

Building a TOTP Desktop Client in Go: Algorithm to Keychain

Source: lobsters

TOTP looks deceptively simple. The algorithm fits in about fifteen lines of Go, and you could be generating valid codes within an hour of starting. Building a proper desktop client, the kind you’d actually trust with your accounts, takes considerably more thought.

This video on developing a 2FA desktop client in Go covers the practical journey of building such a tool, and it’s worth watching for the decisions involved. But there’s a lot of underlying territory worth mapping out: the actual TOTP math, what the Go library ecosystem offers, how to pick a GUI framework, and, most importantly, how to handle secrets without introducing new vulnerabilities.

The TOTP Algorithm

RFC 6238 describes TOTP as an extension of HOTP (RFC 4226), swapping the HOTP counter for a time counter. The time counter is floor(unix_time / 30), giving you a value that changes every 30 seconds. Everything else is HMAC-SHA1.

func generateTOTP(secret []byte, t time.Time) string {
    counter := uint64(t.Unix()) / 30
    msg := make([]byte, 8)
    binary.BigEndian.PutUint64(msg, counter)

    mac := hmac.New(sha1.New, secret)
    mac.Write(msg)
    hash := mac.Sum(nil)

    offset := hash[len(hash)-1] & 0x0F
    code := (uint32(hash[offset]&0x7F)<<24 |
        uint32(hash[offset+1])<<16 |
        uint32(hash[offset+2])<<8 |
        uint32(hash[offset+3]))
    return fmt.Sprintf("%06d", code%1_000_000)
}

That offset trick is called dynamic truncation: you use the last nibble of the HMAC output to pick a 4-byte window, then mask off the top bit to avoid signed integer problems. The remaining 31 bits, modulo 10^6, give you the 6-digit code.

The spec also defines SHA-256 and SHA-512 variants of TOTP, but virtually no service uses them. The real world runs on SHA-1, and compatibility with Google Authenticator means your app needs to handle it correctly.

One detail that catches people: secrets in QR codes are base32-encoded per RFC 4648. You must decode them before passing to HMAC. The base32 alphabet is A-Z2-7, and convention strips padding characters before encoding the URI. Go’s standard encoding/base32 handles this, but you need base32.StdEncoding.WithPadding(base32.NoPadding) to match what most services generate.

Choosing a Library

Unless you’re deliberately keeping dependencies minimal, pquerna/otp (v1.4.0) is the right choice. It covers TOTP and HOTP, handles base32 decoding internally, generates QR code images via the barcode library, and parses otpauth:// URIs:

key, err := otp.NewKeyFromURL(otpauthURI)
// key.Secret(), key.Issuer(), key.AccountName(), key.Period()

code, err := totp.GenerateCodeCustom(key.Secret(), time.Now(), totp.ValidateOpts{
    Period:    uint(key.Period()),
    Digits:    key.Digits(),
    Algorithm: key.Algorithm(),
})

The library’s ValidateCustom function handles clock skew tolerance, accepting codes from the previous and next 30-second windows. That matters for users whose system clocks drift slightly, and it’s one of those things you want a library to handle rather than rolling yourself.

The OTPAuth URI Format

The otpauth:// scheme is Google’s de facto standard for transferring 2FA secrets via QR code. A full URI looks like:

otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30

The host segment specifies totp or hotp. The path is the label, often issuer:accountname. If both a label prefix and an issuer parameter exist, they should match; the label prefix takes display priority when the parameter is absent.

Go’s net/url package handles the structure fine for manual parsing. The tricky part is normalizing the label: strip the leading slash, URL-decode it, then split on the first colon to separate issuer from account name. pquerna/otp handles all of this via otp.NewKeyFromURL, so use that unless you have a specific reason to parse manually.

Picking a GUI Framework

This is where Go desktop development gets opinionated. There’s no obvious answer, and each option involves a real trade-off.

Fyne (v2.5.x) is the most practical starting point. It uses OpenGL for rendering, produces cross-platform binaries with no native dependencies on end-user machines, and has a widget library that covers 90% of what a 2FA app needs: list views, entry fields, dialogs, clipboard access. Binaries come out around 20MB before stripping, and the look is Fyne’s own rather than native on any platform. For a security-adjacent tool, some users may find the unfamiliar appearance disorienting, though that’s perception rather than substance.

list := widget.NewList(
    func() int { return len(accounts) },
    func() fyne.CanvasObject { return widget.NewLabel("") },
    func(id widget.ListItemID, obj fyne.CanvasObject) {
        obj.(*widget.Label).SetText(accounts[id].Name)
    },
)

Gio is the more principled choice. It uses immediate-mode rendering, meaning no retained widget state to manage, and it produces small binaries with excellent performance. The learning curve is steep; Gio requires thinking in terms of layout constraints and paint calls rather than widget hierarchies. For a focused tool with a handful of screens, it’s worth the investment if you want fine-grained control.

Wails (v2.x, with v3 in late RC) takes a different approach: Go handles business logic, and a web frontend runs inside a native webview. If you or your team are comfortable with web technologies, Wails produces the most visually polished results. The trade-off is a webview dependency and the platform-specific behavior that comes with WebKit on macOS, WebView2 on Windows, and WebKit2GTK on Linux.

For a first project, Fyne’s documentation and community support create the least friction. For a production tool you plan to maintain long-term, Wails v3 is worth evaluating.

Secret Storage: The Part That Matters

The TOTP algorithm is public math. The secret key is the actual security, and where you store it determines the real attack surface of your application.

Storing base32 secrets in a plain config file means any process running as the same user can read your 2FA secrets. That defeats the purpose.

The right approach is the OS keychain. zalando/go-keyring wraps macOS Keychain, Windows Credential Manager, and Linux’s SecretService under a single interface:

import "github.com/zalando/go-keyring"

// Store a secret
err := keyring.Set("my2faapp", "user@example.com", base32Secret)

// Retrieve it
secret, err := keyring.Get("my2faapp", "user@example.com")

// Remove it
err = keyring.Delete("my2faapp", "user@example.com")

On macOS, entries are protected by Keychain ACLs and encrypted by the OS. On Windows, DPAPI handles encryption tied to the user’s login credentials. On Linux, SecretService integrates with GNOME Keyring or KWallet.

99designs/keyring is a more feature-complete alternative with an encrypted file fallback for environments without a system keychain, useful for headless machines or CI contexts where you still need access to stored secrets.

Once secrets are in memory, handle them as []byte rather than string. Go strings are immutable; you cannot zero them. With byte slices, you can explicitly overwrite the memory before the GC reclaims it:

defer func() {
    for i := range secretBytes {
        secretBytes[i] = 0
    }
}()

This is a best-effort mitigation rather than a guarantee, since the GC may have copied the slice before you zero it, but it reduces the window during which secrets are accessible in a memory dump.

Clipboard Lifecycle

Copying a 6-digit code to the clipboard is the core action in a 2FA app, but clipboard contents persist until overwritten, and every other application on the system can read them.

The mitigation is auto-clearing: overwrite the clipboard after a short timeout, typically 10 to 30 seconds, but only if the clipboard still contains your code:

copiedCode := currentCode
time.AfterFunc(30*time.Second, func() {
    if w.Clipboard().Content() == copiedCode {
        w.Clipboard().SetContent("")
    }
})

The conditional check matters. If the user has already pasted and moved on, clearing the clipboard is disruptive. The check also prevents clearing a code that a different app has since replaced.

Pair this with a visible countdown timer showing time remaining in the current 30-second window. Users need to know when a code is about to expire, and a ticking indicator also signals that the app is working correctly rather than frozen.

Putting It Together

The engineering stack for a solid Go 2FA desktop client is small and manageable. pquerna/otp handles the cryptographic core and URI parsing. zalando/go-keyring or 99designs/keyring handles secret storage. Fyne or Wails handles the UI. The total dependency surface is contained, and each component has a clear, auditable role.

The deeper point is that the TOTP algorithm is not where the complexity lives. It’s well-specified, well-understood, and straightforward to implement or delegate to a library. The interesting engineering is in the surrounding surface: what GUI model fits Go’s idioms, how OS keychain APIs vary across platforms, and what clipboard lifecycle management looks like in practice. Building a 2FA client turns out to be a genuinely useful exercise in writing a security-sensitive desktop app in Go, and the space for well-built tools in this category is more open than you might expect.

Was this interesting?