DEV Community

Cover image for Authelia passkeys with TOTP as break-glass: why the config diff was empty
Christian Anderson
Christian Anderson

Posted on

Authelia passkeys with TOTP as break-glass: why the config diff was empty

Everything behind my front door goes through one login portal: photos, password vault, the VPN control plane, my agent dashboard. It runs Authelia, and for a long time the second factor was a TOTP code out of an authenticator app.

Now it's a passkey, with TOTP kept only for the day I lose the authenticator.

I went looking for the configuration change that expresses that. There isn't one. The diff is empty, and understanding why it's empty is the whole point of this post.

What the policy actually says

The relevant lines, unchanged before and after:

access_control:
  default_policy: 'two_factor'
  rules:
    - domain: '*.example.internal'
      policy: 'two_factor'
Enter fullscreen mode Exit fullscreen mode

And every OIDC client registered with it:

      - client_id: 'some-app'
        authorization_policy: 'two_factor'
Enter fullscreen mode Exit fullscreen mode

two_factor is a count, not a method. It says: a password, and then one more thing you have proven you control. It does not say which thing. Authelia will accept any second factor the user has enrolled, and the user decides which one to present at the prompt.

So "passkey normally, TOTP only if I'm locked out" is expressed by what I enrolled, not by anything in the file. My config has no webauthn: section and no totp: section at all — both run on v4.39 defaults, because nothing needed saying.

If you came here expecting a config diff, that expectation is the misunderstanding. It was mine too.

Why the passkey is the normal one now

TOTP is a shared secret. When you scan that QR code, the server keeps a copy of the seed — something that can regenerate your codes. And the code itself is six digits that can be read over your shoulder, screenshotted, typed into the wrong box, or relayed by a real-time proxy: a fake login page that takes your code and replays it to the real portal inside the 30-second window. You do everything right and still lose.

A passkey is different in kind, not in degree:

  • The private half never leaves the authenticator. The server stores a public key, so a database leak gives an attacker nothing to replay.
  • The browser binds the credential to the origin. A lookalike domain cannot invoke the passkey for the real one — not "shouldn't", cannot. The relay attack above has nothing to relay.

That second property is the one that matters. Most 2FA advice is about making the user more careful. This one removes the situation where carefulness was the defence.

Why TOTP is still enrolled

Because recovery beats purity.

Every service in the house sits behind this portal. If the passkey lives on one device and that device goes in a canal, an untested recovery path is not a recovery path. TOTP as break-glass is the honest trade: normal logins get the unphishable factor, and there is a second door I have actually opened.

The uncomfortable part, stated plainly: a break-glass path is a path. An attacker who can steer me to the "use a code instead" link gets phishability back. Keeping TOTP means keeping that floor. I accept it, knowing it's a real cost and not a technicality — the alternative is a single device standing between me and everything.

Where 2FA can't go

The other thing reading that config taught me is that two rules in it are deliberate holes, and both are the same shape.

A vault whose native clients aren't browsers. The browser extension and the mobile apps talk to API paths directly. They can't follow an HTML redirect to a login portal, so gating those paths doesn't secure them — it breaks every client. The rule gates the browser-only admin panel and bypasses the rest:

    - domain: 'vault.example.internal'
      resources: ['^/admin.*$']
      policy: 'two_factor'
    - domain: 'vault.example.internal'
      policy: 'bypass'
Enter fullscreen mode Exit fullscreen mode

Webhook endpoints. A webhook is a machine posting to a URL. There is no human to prompt.

The generalisation: an SSO portal authenticates browsers. Anything that isn't a browser needs its own mechanism — a token, an API key, a signed payload — and pretending otherwise produces either a broken app or a bypass rule you wrote in a hurry and never revisited. Write the rule narrow, scope it to paths rather than whole hosts, and put the reason in a comment next to it. Mine has one, and it's the only reason I trusted it a year later.

The takeaway

Two factors is a policy. Which two is an enrolment. Those live in different places, and only one of them is in your config file.

If your portal already says two_factor, you are one enrolment screen away from unphishable logins — no redeploy, no YAML, and no reason to put it off as a project.


🤖 Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.

Top comments (0)