Insight

The Authentication Model Contradiction Nobody Talks About

The real trade-off between live TOTP lists, per-use authentication, and vault design.

The Signet shield mark on a restrained dark editorial card
Attomus insight

The real trade-off between live TOTP lists, per-use authentication, and vault design.

Part 1 of 5 in Building Signet

Authenticator apps look simple. They generate six-digit codes. The cryptography is forty years old. The protocol is an IETF RFC from 2011. You could probably implement the core algorithm in a lunch break.

The genuinely hard part isn’t the TOTP maths. It’s a design tension baked into the product category itself, one that every authenticator app has to resolve, that most resolve quietly and without documentation, and that the industry has somehow decided not to discuss openly.

Here’s the problem. A live code list requires the app to have already loaded your secret keys into memory and started computing. That has to happen before you ask for any individual code; it’s a precondition for the display. So when the biometric prompt appeared at launch, it wasn’t protecting each code access; it was opening the session that kicked off the computation. The keys were in memory before you asked for anything.

Per-use authentication and a live code list can’t coexist. An app can have one or the other. The way different apps handle this tells you a lot about their actual security model versus their stated one.

What the live list requires

A TOTP code is computed from two inputs: the secret key and the current timestamp. The output changes every thirty seconds. Most authenticator apps show you a live countdown timer (a progress bar draining toward zero, a number ticking down) alongside the current code.

To render that countdown, the app needs to know the current code. To know the current code, it has to have computed it. To have computed it, it needs the secret key. The secret key has to be in memory.

This is not a flaw or a shortcut. It is an unavoidable consequence of showing live codes. The moment you decide your UI will include a progress bar counting down to the next code rotation, you have committed to keeping secret key material in memory for the duration of that display.

Most apps make this choice. Almost none of them say so explicitly in their security documentation.

The two coherent models

There are exactly two architecturally honest designs for an authenticator app. Everything else is a hybrid that inherits the weaknesses of both.

Model A: session-scoped secrets in memory.

The user authenticates once. The app derives a session, loads the secret keys into memory, and begins computing codes. It shows a live account list. It shows countdowns. It allows copy-to-clipboard on tap. The session expires after a configurable window (thirty seconds, five minutes, never) and on expiry, the in-memory keys are cleared, codes are hidden, and the app returns to a locked state.

The security property is: secret key material is in memory only during an active session, for a window the user controls. Authentication at session start is the gate. There is no per-code authentication because there doesn’t need to be; the session already established trust.

This model is honest. It documents clearly that secrets are in memory during the session. It gives users a meaningful control (the timeout) and a meaningful guarantee (keys are cleared on expiry). It does not claim more than it delivers.

Model B: per-use authentication, dark list.

The user opens the app and sees their account names, and nothing else. No codes. No timers. To generate a code for a specific account, the user taps it and authenticates, biometric or PIN. The code is shown for a fixed window, then cleared. The secret is loaded from secure storage, used once, and discarded. The list view is always dark.

The security property is: secret key material is in memory only during a single generation event, for a fixed brief window. Each code generation is independently authenticated. There is no persistent session.

This model is also honest. The trade-off is explicit: the UX is worse (no live view, no countdown), in exchange for a genuine per-use authentication guarantee.

Why most apps implement neither cleanly

The problem is that Model B looks worse in a screenshot. No live codes, no timers, a static list of account names. Users accustomed to Google Authenticator or Authy will wonder if the app is broken.

So most apps implement Model A (session-scoped secrets, live codes, countdown timers) but present it in Model B’s language. They say “biometric-protected” in the App Store listing. They show a lock screen on launch. They use the word “vault.”

None of that is false, exactly. The secrets are protected by biometrics to start the session. After that, they’re in memory, and the biometric prompt won’t appear again until the session expires. If someone grabs your unlocked phone while the app is open and the session is active, they can copy every code. The lock screen on launch does not protect against that.

This isn’t a damning security failure. It’s the correct trade-off for most users. But calling it “biometric-protected” in a way that implies per-use authentication is not the same as documenting it honestly.

The apps that have thought hardest about this tend to be transparent in a different way: they offer a configurable session timeout, they document what “session” means, they’re explicit about what gets cleared on lock. That’s the honest version of Model A. It doesn’t pretend to be Model B.

The vault boundary is the real question

There’s a deeper issue underneath the session model, and it’s the one we spent the most time on when building Signet.

The question isn’t just “is the user authenticated?” It’s “what does authentication authorize?”

In most authenticator apps, the answer is: it authorizes you to see codes. Authentication is an access control on the UI. You pass biometrics, the UI shows you things.

The problem with this model is that “the UI showing you things” and “the secrets being in memory” are conflated. The authentication gate is on the display layer. The secrets might be accessible to other code in the process regardless of whether the UI is in a “locked” state.

A more principled model separates the authorization boundary from the display boundary. Authentication doesn’t unlock the UI; it establishes a session under which the vault will compute codes. The vault is a discrete component with its own state. It accepts requests from authorized callers inside its lock window and refuses them outside it. Whether the UI shows a lock screen or not is irrelevant to what the vault will do.

This matters most in the boundary case: a second piece of code in the same app (a widget extension, a backup export function, a share sheet) that might want to request key material. If authentication only gates the UI, those components can potentially access secrets regardless of session state. If authentication gates the vault itself, they can’t.

The practical implication: raw secret bytes should never cross the vault boundary. Callers don’t get the key; they get the computed code, or they get an encrypted backup package. The vault handles both the computation and the session state. The UI layer gets outputs, not material.

This is a more expensive design to build correctly. The vault has to understand not just cryptographic operations but session state, expiry, and authorization context. But it’s the only design where “session expired” actually means what users think it means; not just “the UI shows a lock screen” but “the vault will refuse to compute codes until you authenticate again.”

What the honest trade-off looks like

If you’re building a TOTP app, or evaluating one, the questions worth asking are:

On the session model: Is it documented? Is the timeout configurable? Is there a meaningful distinction between “app locked” and “secrets cleared”? What happens to in-memory state when the session expires? What happens if the app is backgrounded?

On the vault boundary: Can any code path in the app return raw secret bytes to a caller? Does the backup export path encrypt inside the vault before returning bytes, or does it return plaintext and expect the caller to encrypt? Is session state maintained by the vault, or is it maintained by the UI layer and honoured on trust?

On the claims made: Does “biometric-protected” mean per-use, or session-start? Does “secure enclave” describe where the secrets live, or just the session key? Is there an architectural document that makes these distinctions, or is the security model implied by marketing language?

Most apps in this category are not insecure. They’ve made reasonable trade-offs and implemented them competently. What most of them haven’t done is write down the model clearly enough that a security engineer reading it would understand exactly what is and isn’t guaranteed, and under what conditions.

That’s the gap worth closing. The two coherent models both have defensible security properties. The hybrid that pretends to be one while implementing the other is the only design that actually fails; not cryptographically, but as a trust relationship with the user.

Attomus Signet — offline 2FA, on the App Store and Google Play. Read the Signet assurance record.