Skip to content

Passkeys

Describes Hibernator chart 0.12.44

Passwordless login with FIDO2 security keys and platform passkeys. A user who is already signed in with an emailed one-time code can enrol one or more authenticators — a laptop’s Touch ID or Windows Hello, a phone, a hardware key — and from then on signs in with a single click, typing no address and waiting for no code.

It is off by default, opt-in per user once it is on, and the one-time code never goes away.

This is convenience, not phishing resistance

Section titled “This is convenience, not phishing resistance”

This is a convenience and availability feature. It is not phishing resistance. Every part of the design rests on that sentence, so read it before you decide what the feature buys your deployment:

  • The one-time code remains a permanent, always-enabled fallback. It cannot be turned off per user or globally. Anyone who can receive mail at an address your config.auth.allowedEmailDomains allows can sign in without ever touching a passkey, so the security floor of the install stays the email flow, not the passkey flow.
  • Deleting your last passkey is allowed, needs no extra warning beyond the ordinary delete confirmation, and needs no admin involvement. Losing a key means signing in with a code, as before.
  • A passkey is a replacement first factor, not a second factor. There is no mode in which someone is asked for both a passkey and a code.

What it does buy: no code round-trip on every login, a credential that cannot be read out of an inbox, and a per-user list of exactly which authenticators can sign in — which the user can revoke themselves.

Three values, and no more:

config:
auth:
webauthn:
enabled: true
# Optional. Defaults to the host of the address passkeys belong to.
rpId: ""
# Optional. Shown in the browser's passkey prompt. Defaults to
# config.clusterName, or "Hibernator" when that is empty.
displayName: ""

Everything else about the feature — the cap of five passkeys per user, the ceremony timeouts, how fresh a login has to be before someone can enrol, the login rate limit — is fixed in the code and is not configurable.

The API checks the deployment’s URLs once at startup. If anything below is wrong it logs one error line naming the URL and the reason, leaves the feature disabled and starts normally:

Refusal Why
Neither config.internalUrl nor config.externalUrl is set There is no address to bind passkeys to
The address is not an absolute URL with a scheme and a host, e.g. hibernator.example.com There is no origin to derive a passkey address from
The address carries a path, e.g. https://host/hibernator-a/ See the next section — this one loses data
The host is an IP address, including 127.0.0.1 Browsers refuse an IP as a passkey address, whatever the scheme. This check runs before the scheme check, so a loopback IP is refused even though a browser trusts the origin
The scheme is http on anything but localhost Browsers expose no passkey API on an untrustworthy origin, so the button would never work. localhost is the only spelling that works over http. config.allowInsecureExternalUrl does not change this — it cannot make a browser change its mind
rpId carries a scheme, a port or a path An rpId is a bare domain and nothing else
rpId is set to something that is not the host or a parent domain of it The browser would reject every ceremony

A refused deployment is not broken and shows no error to users: the passkey button, the My Account passkey section and the enrolment endpoints are simply absent. Check the API log after enabling it:

Terminal window
kubectl logs -n hibernator deployment/hibernator-app -c api | grep -i webauthn

WebAuthn enabled with an rpId and rpOrigin means it is live. WebAuthn stays disabled: the relying party was refused names what to fix.

Passkeys belong to config.internalUrl when it is set, and to config.externalUrl otherwise. That one address — the RP origin — is where users enrol passkeys, where they manage them, and the only place they can sign in with one. Passkey login is not available on the other address; there, the login page shows a line naming the host where passkeys live and sends the user there.

A URL carrying a path disables the feature, and the reason is worth understanding before you try to work around it:

A passkey address is a bare domain. A path plays no part in it at all, so https://portal.example.com/hibernator-a/ and https://portal.example.com/hibernator-b/ are one passkey address, not two. Inside the authenticator a passkey is keyed by that domain plus the user, so with one person’s address behind both installs, enrolling a passkey on a would silently replace the passkey they already hold for b. There is no error, no prompt and no way to notice afterwards: the b passkey is simply gone from the authenticator, while Hibernator still lists it as enrolled.

The startup refusal is therefore a data-loss guard. If you run several Hibernators behind one hostname, give each one its own hostname before enabling passkeys, or leave the feature off.

rpId overrides the domain passkeys are bound to, and the only legal override is a registrable parent of the origin host — example.com for an install served at hibernator.example.com. Use it when the address is going to move between subdomains and the passkeys should survive the move. It is not a way to share passkeys between two installs: each keeps its own store, so under a shared rpId a user is offered both installs’ passkeys at either address and the wrong one is refused as not registered here.

Changing rpId on a live install detaches every passkey already enrolled — they stay in the ConfigMap, are shown in the user’s list flagged as unusable, and never sign anyone in again. The API logs a count of those records at startup (Passkey store ready, field unusableRecords), so the blast radius is visible immediately rather than through user reports.

Enrolling. Sign in with a one-time code as usual, open My Account, and press Add passkey. The browser asks which authenticator to use and the passkey is stored under a name the user can edit afterwards.

Enrolment requires a sign-in less than 15 minutes old, so that a stolen session cannot be turned into permanent access. When the session is older the button says Add passkey — needs a fresh code and asks for a code before the browser prompt, without leaving the page. A passkey login counts as a fresh sign-in too, so adding a second or third key needs no email at all.

Each user gets five passkeys. The counter on the screen reads N of 5 used on this instance. Attempting a sixth is refused with a message naming the limit; they delete one first.

Signing in. The login card carries a Sign in with a passkey button beside the email form. One click, no address typed and no code. If the browser finds no passkey for this install the card says so and points at the code form above.

Deleting. The same screen deletes a passkey, after a confirmation dialog naming it. Deleting the last one is no different — no extra warning, no admin involvement. That user falls back to one-time codes.

Both enrolling and deleting send that person a mail at their own address naming this install, so an enrolment they did not perform is visible to them rather than only to an auditor. Both actions are also written to the audit log, which an admin reads in the UI.

These are consequences of the design, documented rather than fixed. Each is a deliberate decision, not an open bug.

  1. A passkey works on one install only. Passkeys are stored per Hibernator, and the address they are bound to is per Hibernator. Someone working across two products in five environments enrols ten times — once on each. The UI hints at it in the words on this instance and nowhere else. There is no mechanism that could share them; a passkey bound to two addresses is not a thing the browser will make.
  2. A changed email address leaves the old record live. Hibernator has no way to recognise “same person, new address”. After a rename the person enrols again under the new address, their authenticator then offers two passkeys for this install, and the old one still signs in as the old identity — with whatever admin rights and domain allowance that address has. Delete the old record when you rename someone, using the section below.
  3. A restore can re-arm a revoked key. Restoring the hibernator-passkeys ConfigMap from a backup brings back every passkey it held, including one a user deleted because the device was lost or stolen, and drops anything enrolled since the backup. Nothing detects this and nothing can: see backup and restore.

There is deliberately no admin screen for other people’s passkeys. The operator escape hatch is stronger and needs no UI: delete that person’s key from the hibernator-passkeys ConfigMap and they fall back to one-time codes on their next login.

The ConfigMap holds one key per user, named user-<handle>, whose JSON value carries that person’s address in plain text — this is the one place in Hibernator where an address is stored unanonymised, so that a key can be found by the person it belongs to.

Terminal window
# Find the key belonging to an address
kubectl get configmap hibernator-passkeys -n hibernator -o json \
| jq -r '.data | to_entries[] | select(.value | contains("user@example.com")) | .key'
# Remove it
kubectl patch configmap hibernator-passkeys -n hibernator --type=json \
-p='[{"op":"remove","path":"/data/user-3q2-7wAAAAAAAAAAAAAAAA"}]'

Use it when someone leaves, when an address is renamed, or when a user has lost the only device holding their passkey and wants a clean slate. It takes effect on their next sign-in: the login path re-reads the ConfigMap rather than serving a cache.

Two things to tell the person afterwards:

  • Their authenticator still holds the old passkeys, and will keep offering them. Pressing Sign in with a passkey with one tells them it is no longer registered here and asks the browser to drop it; they can also delete it in their password manager.
  • Enrolling again gives them a fresh record. Nothing of the old one comes back.

Hibernator’s RBAC does not include delete on ConfigMaps and it never deletes this one; only keys inside it are ever removed, and only by you.

A passkey costs roughly 750 bytes stored, and a user record about 150 bytes on top. Hibernator treats a ConfigMap as critically large at 800 KiB, which puts the practical ceiling near 1000 passkeys — about 500 users at two each. The hard 1 MiB wall Kubernetes imposes is further out, near 1400. That ceiling is documented, not enforced: nothing refuses an enrolment because the store is large.

Nothing is ever evicted. Dropping a passkey to make room would silently lock someone out, which is the one failure this feature must not have — so the store only grows, and the warning has to arrive while there is still room to act:

HibernatorPasskeyStoreSizeWarning fires at 600 KB, well below the generic ConfigMap warning, and is on by default whenever metrics.prometheusRule.enabled: true. See monitoring.md. When it fires, the store is holding several hundred users’ passkeys; the fix is to remove the records of people who have left, using the section above.

Passkey login is correct at any replica count

Section titled “Passkey login is correct at any replica count”

Worth stating plainly, because it is the one place this feature is stronger than what it replaces: passkey login is the first authentication path in Hibernator that is correct at any replica count. One-time codes and the login rate limiter both live in memory inside a single API replica, so at replicaCount.app above 1 a code verifies only on the replica that issued it. Passkeys have no such state — every replica can complete a login started on any other.

The default replicaCount.app is 1, where none of this matters.

The passkeys live in one ConfigMap, hibernator-passkeys, in the release namespace. It holds one entry per person who ever enrolled: the address, the random handle that their authenticator registered against, and one row per passkey.

The chart does not create it and does not protect it. The API creates it at runtime on the first enrolment, as it creates hibernator-state and hibernator-audit-log. Helm does not own it, so helm uninstall leaves it, and a deleted namespace takes it. Hibernator ships no backup of any kind: no Velero integration, no export endpoint and nothing scheduled. You arrange the backup.

Nothing that Hibernator runs can delete it. Neither the API Role nor the controller ClusterRole holds delete on ConfigMaps, and enrolment and removal only change keys inside it. A loss therefore always comes from outside: a deleted namespace, a rebuilt cluster or a restore of the whole namespace.

Back it up, but you do not have to, and a restore is better than a clean loss. Nobody is locked out when this ConfigMap is lost: every user falls back to the one-time code. After a restore, even from an old backup, most people sign in as before. After a clean loss, every passkey in every authenticator is an orphan, and each user keeps a second, dead entry in their password manager.

Terminal window
kubectl get configmap -n hibernator hibernator-passkeys -o yaml \
> hibernator-passkeys-$(date +%F).yaml

Two things about that file:

  • It contains raw email addresses. This ConfigMap is the one exception to Hibernator’s anonymisation: logs, audit entries and event streams never carry a raw address, but this store must, because an address ties a passkey to a person. Treat the file as personal data. Keep it where your other personal data goes, not in a ticket or a shared bucket.
  • It contains no secret. The private key of a passkey never leaves the authenticator, so the file holds a public key and some metadata. Nobody can sign in with the file.

To restore, read the three cases below first. Any restore can bring back a passkey that its owner deleted on purpose.

  1. Remove metadata.resourceVersion, metadata.uid and metadata.creationTimestamp from the file. The API server refuses an old resourceVersion on a live object, and a new object must have none.

  2. If the ConfigMap is still there, replace it:

    Terminal window
    kubectl replace -f hibernator-passkeys-2026-09-01.yaml
  3. If the ConfigMap is gone, create it:

    Terminal window
    kubectl create -f hibernator-passkeys-2026-09-01.yaml

Use replace, not apply. apply does a three-way merge. The API created this ConfigMap at runtime, so it carries no kubectl.kubernetes.io/last-applied-configuration, and apply cannot calculate a deletion: every key that is in the cluster but not in your backup stays. A test showed this: a ConfigMap with the keys a and b, applied from a file with only a, kept b. kubectl replace -f with the same file removed it. replace writes the object you backed up and nothing else, and the cases below expect that.

This is the harmless case. Everyone who enrolled before the backup signs in as before.

It does not cause clone warnings. The clone check fires when the counter that an authenticator sends is not above the stored one. A restore moves the stored counter back, so the next sign-in brings a counter far above it, and the stored counter jumps forward.

A restore costs a detection gap. Every counter value between the backup and now is accepted again, so a cloned hardware key that uses that range goes unnoticed. Hibernator accepts that gap. Passkey sign-in here is a convenience and not phishing resistance: the one-time code stays open to everyone, so the code, not the passkey, is the security floor of the install. Synced passkeys send no clone signal at all.

For a passkey enrolled after the backup, its owner is in the next case.

Nobody is locked out. Every user falls back to the one-time code, signs in and enrols again.

Every passkey still in an authenticator becomes an orphaned passkey. Hibernator has no record of it, but the browser keeps offering it at each sign-in, because the web address it was registered against did not change. It can never sign anyone in.

Nothing in the UI can list orphaned passkeys, because no record of them is left. This is the difference from a passkey that Hibernator still holds and shows its owner as unusable.

It heals itself, and it costs each user one failed attempt. Hibernator answers that attempt with

This passkey is no longer registered on this instance. Sign in with a code and enrol again. The old one no longer works and can be deleted wherever it is stored.

With that answer, a browser that supports the signal is told that the credential is unknown, and it removes the dead entry from the authenticator. Enrolling again then registers a new passkey. Expect questions about the failed attempt, not about lockouts.

This is the worst case, and the only one that does not heal itself.

The ConfigMap holds one key per user, not one per passkey. A restore of one key restores that person’s whole record: it brings back every passkey they deleted after the backup, also one they deleted because the hardware key was lost or stolen. It also drops every passkey they enrolled after the backup.

A restore can bring back a revoked key. Nothing guards against this, and nothing can. A record of the deletion would have to survive the restore it guards against. It would live in the same ConfigMap or in hibernator-audit-log, and an operator restores that one together with it.

Nothing detects a restore either, on purpose: no log line, no startup warning and no banner. So you send the instruction yourself, after any restore of hibernator-passkeys, whole or partial:

Tell users to open their passkey list and delete anything they do not recognise or no longer trust.

What a user does tells where that list is and what it shows.