Reader: the operator who turns on WebAuthn MFA for the admin and operator accounts of a Polaris deployment. Job: enroll credentials, set enforcement deadlines, escalate the authenticator policy, recover a locked-out admin, and know how to lift the requirement on one account.
Last reviewed: 2026-09-02
The implementation is
polaris_web/webauthn_auth.py; the
/login route in polaris_web/app.py consults it
on every password login. Test coverage is WebAuthnCeremonyTests and
WebAuthnCredentialLookupTests in
polaris_web/test_app.py. The infrastructure
is complete; what the operator decides is WHEN enforcement begins for each
account.
- Mandatory for the admin role once a deadline is set. The
AppUser.webauthn_required_aftercolumn governs enforcement: NULL means never enforced; a timestamp means the state machine below applies.polaris-create-operator.shsets it to now + 30 days for every new admin. - Optional for the operator role. An operator who enrolls a credential must present it from then on; one who has not enrolled logs in with the password alone.
- Exempt for the auditor role (
ROLES_EXEMPT_WEBAUTHN). Auditors are read-only; single-factor for a read-only role is the accepted risk. - Post-quantum ready. Registration options offer ML-DSA-65 (COSE -49)
first, then ES256, EdDSA, and RS256. An authenticator that implements
ML-DSA enrolls a post-quantum credential and
/settings/webauthnlabels it "ML-DSA-65 (post-quantum)"; every shipping authenticator today enrolls a classical one.
The four states webauthn_status_for_user can return:
| State | webauthn_required_after |
credential count | Login behavior |
|---|---|---|---|
not_required |
NULL, or the auditor role, or an operator with no credential | 0 | completes with the password |
grace_period |
in the future | 0 | completes, with a warning banner naming the days left |
mfa_required |
any | 1 or more | forwards to /auth/webauthn/assert |
mfa_overdue |
in the past | 0 | refused with HTTP 401; audited as LOGIN_FAILED with detail WebAuthn enrollment deadline passed; no credential enrolled |
Every knob is read at start from the environment (the production compose
file passes each one through from the shell or /etc/polaris/polaris.env;
deploy/linux/polaris.env.example
lists them commented out). A bad value refuses the boot.
| Variable | Values | Effect |
|---|---|---|
POLARIS_DOMAIN |
hostname | the WebAuthn relying-party ID; assertions are origin-bound to it. Falls back to localhost in dev |
POLARIS_WEBAUTHN_RP_NAME |
text, default Polaris |
the relying-party display name the browser shows during the ceremony |
POLARIS_WEBAUTHN_HARDWARE_ONLY |
1 or unset |
at registration, ask the browser for a cross-platform authenticator only (YubiKey class), so platform authenticators (Touch ID, Windows Hello, Android biometric) are not offered; the server does not verify attachment. Default: both |
POLARIS_WEBAUTHN_ATTESTATION |
none (default), indirect, direct, enterprise |
the attestation conveyance asked of the browser at enrollment |
POLARIS_WEBAUTHN_USER_VERIFICATION |
preferred (default), required, discouraged |
whether the PIN or biometric is demanded; required is checked server-side on enrollment AND every assertion |
POLARIS_WEBAUTHN_REQUIRE_ATTESTATION |
1 or unset |
refuse an enrollment whose attestation format is none; pair with direct |
POLARIS_WEBAUTHN_ALLOWED_AAGUIDS |
comma-separated UUIDs | refuse any authenticator model not on the list at enrollment |
Phase 6 below explains how the last four combine.
# Confirm the WebAuthn substrate is present
psql -d polaris -c "\d OperatorWebauthnCredential"
psql -d polaris -c "\d AppUser" | grep -E "webauthn_required_after|recovery_code_hash"
# Confirm /settings/webauthn serves (login required)
curl -fsS -o /dev/null -w '%{http_code}\n' "https://${POLARIS_DOMAIN}/settings/webauthn"
# expect 302 to /login when not logged in
# (on the mac dev launcher the app listens on http://localhost:2222 instead;
# in the compose stack the app is reachable only through Caddy)
# Confirm the WebAuthn ceremony tests pass (needs the 3.12 venv, Postgres, Redis)
cd /path/to/polaris && ./scripts/polaris-test.sh quick 2>&1 | grep -i webauthn
# Confirm the structural invariants pass
cd /path/to/polaris && python3 -m polaris_checks.runIf any of these fail: stop. Run ./scripts/polaris-migrate.sh --up first
(the WebAuthn credential table and the recovery-code column are migrations
2026-05-14-002 and 2026-05-14-003; the WEBAUTHN_REGISTRATION_REFUSED
event type that Phase 6 audits is admitted by 2026-09-01-001, and without it
the refusal audit row silently fails the chk_authaudit_event_type CHECK).
If still failing, file an issue per
SECURITY.md.
The operator running the rollout enrolls first, so the recovery flow in Phase 2 never has to start from a fully locked-out system.
-
Sign in with the password as the admin (allowed while the account is in
grace_period). -
Open
/settings/webauthn. -
Click Enroll WebAuthn credential. The browser prompts for a hardware security key or a platform authenticator, subject to
POLARIS_WEBAUTHN_HARDWARE_ONLY. -
Give the device a label (for example
YubiKey 5C work-laptop) and confirm. The credential is stored inOperatorWebauthnCredential. -
Verify the audit row landed:
SELECT event_timestamp, event_type, username FROM AuthAuditLog WHERE event_type = 'WEBAUTHN_REGISTERED' ORDER BY event_timestamp DESC LIMIT 5;
-
Log out and log in again. The login now passes through
/auth/webauthn/assert. Confirm this works end to end before moving on.
Enroll a second credential on your own account: one platform authenticator
and one external key. Hardware loss is the most common operator-side lockout
cause, and two credentials remove the single point of failure. A lost
authenticator whose credential row still exists keeps the account in
mfa_required; the emergency window in Recovery does not lift
that state, so delete the lost credential's row first.
Before enforcing on your own account, make sure a second admin exists who can authorize an emergency window for you (Recovery: second-admin pairing).
# Confirm a second admin exists
psql -d polaris -c "SELECT user_id, username, role FROM AppUser
WHERE role = 'admin' ORDER BY user_id"
# If only one admin exists, create another (password read from stdin or a file, never argv)
./scripts/polaris-create-operator.sh --username second-admin --role adminHave the second admin complete Phase 1 for their own account.
A deployment that will only ever have one admin binds a printed recovery mnemonic instead (Recovery: solo admin with a printed mnemonic). Do this in Phase 2 as well; it is the only self-service recovery path.
scripts/polaris-set-webauthn-deadline.sh
sets webauthn_required_after. The default is 30 days from now; override
with --days N. The script has no --target=docker-stack option: it runs
psql -h $POLARIS_DB_HOST -U $POLARIS_DB_USER -d $POLARIS_DB_NAME (defaults
localhost, polaris_app, polaris) and exits 3 when the database is
unreachable. On the compose stack, where Postgres publishes no host port,
export those variables for a reachable Postgres or set POLARIS_PSQL to a
command that runs psql inside the postgres container.
# Your own account (enrolled in Phase 1)
./scripts/polaris-set-webauthn-deadline.sh --username your-admin --days 30
# The second admin (Phase 2)
./scripts/polaris-set-webauthn-deadline.sh --username second-admin --days 30
# Every admin, or every operator
./scripts/polaris-set-webauthn-deadline.sh --all-admins --days 60
./scripts/polaris-set-webauthn-deadline.sh --all-operators --days 60
# Dry run: show what would change
./scripts/polaris-set-webauthn-deadline.sh --all-admins --days 60 --dry-runThe script:
- refuses
--days 0, with or without--force(exit 5); a negative or non-numeric value is a usage error (exit 2), and there is no way to pass an absolute past date; - refuses a deadline shorter than 7 days unless
--force(exit 6); - refuses to shorten an existing deadline that still has more than 7 days
remaining unless
--force(exit 7); a deadline with 7 days or fewer left can be shortened freely; - refuses a target whose role is not admin or operator (exit 8);
- writes an
AuditAccessLogrow (access_typeWEBAUTHN_DEADLINE_SET,accessed_tableAppUser,access_contextset by: <user> | new_deadline: ...), not anAuthAuditLogrow;--by USERnames the admin running it (default$USER); - returns non-zero on any failure, so cron logs are greppable.
The past-deadline refusal is deliberate: a deadline in the past locks the target out immediately, so an attacker who briefly holds an admin session could otherwise lock out every other admin with one command.
When the deadline arrives:
- Accounts that enrolled continue to log in through WebAuthn
(
mfa_required). - Accounts that did not enroll become
mfa_overdueand are refused. The login page tells them to have a second admin runpolaris-recover-admin.sh, or to use a printed recovery code. Both paths are in Recovery below.
Watch the audit log for LOGIN_FAILED rows whose detail begins WebAuthn enrollment deadline passed. Each one is an operator who did not finish
enrollment in time.
After Phase 4 has been stable for a month or two, restrict new enrollments to external security keys:
# In /etc/polaris/polaris.env (or the deploying shell)
POLARIS_WEBAUTHN_HARDWARE_ONLY=1
# Restart the stack
./scripts/polaris-deploy.sh prodWith the flag set, the registration options ask the browser for a
cross-platform authenticator, so the browser does not offer platform
authenticators at enrollment; the server does not verify attachment, so a
modified client can still enroll a platform credential. Pair the flag with
POLARIS_WEBAUTHN_ALLOWED_AAGUIDS (Phase 6) for a server-enforced fleet
restriction. The flag does not touch the assertion ceremony: existing credentials, platform ones
included, keep working. To force re-enrollment on hardware, delete the
credentials that enrolled without an attestation statement:
psql -d polaris -c "DELETE FROM OperatorWebauthnCredential
WHERE attestation_format = 'none'"
# a rough filter: with the default attestation policy every credential is 'none'This is destructive. Every affected admin drops back to grace_period or
mfa_overdue depending on their deadline; every affected operator-role
account drops to not_required (password-only) until it re-enrolls. Confirm
a recovery path is in place first.
Phase 5 restricts the KIND of authenticator. Phase 6 restricts what the
authenticator must PROVE. Each refusal is audited as
WEBAUTHN_REGISTRATION_REFUSED with policy: in the detail.
- Require user verification.
POLARIS_WEBAUTHN_USER_VERIFICATION=requiredmakes the PIN or biometric mandatory on enrollment AND on every assertion (the UV flag is checked server-side in both ceremonies), so a security key lifted from a desk cannot complete the second factor without its PIN.preferredasks for it but accepts a key that did not perform it;discouragednever asks. - Ask for a real attestation.
POLARIS_WEBAUTHN_ATTESTATION=directasks the browser to pass the authenticator's own attestation statement through (indirectlets the client anonymise it;enterpriserequests the enterprise attestation an authenticator may hold;noneasks for nothing). The library verifies packed, TPM, FIDO-U2F, Android-key, Android-SafetyNet, and Apple statements against their roots. - Refuse enrollments without one.
POLARIS_WEBAUTHN_REQUIRE_ATTESTATION=1refuses a registration whose attestation format isnone, which is what a browser returns when the authenticator cannot or will not attest. Only meaningful together withdirect. - Pin the fleet.
POLARIS_WEBAUTHN_ALLOWED_AAGUIDS=<uuid>,<uuid>refuses any authenticator model not on the list. An AAGUID is only trustworthy inside a verified attestation (undernonethe client zeroes it, so the list would refuse everything), so this step presumes steps 2 and 3.
Existing enrollments are not re-examined when a knob changes; the policy
applies to new registrations and, for user verification, to every assertion.
Credential rows store the attestation format as the wire name (none,
packed, ...), which is what the Phase 5 filter matches; rows enrolled
before the wire name was adopted may hold the enum repr
AttestationFormat.NONE instead, so widen the filter with OR attestation_format = 'AttestationFormat.NONE' when the table predates it.
scripts/polaris-recover-admin.sh
opens a short emergency window during which one admin may log in with the
password alone. It sets the target's webauthn_required_after to now plus
the window (1 to 60 minutes, default 15), clears the failed-login counter and
lockout, and writes an EMERGENCY_PASSWORD_LOGIN_AUTHORIZED row to
AuthAuditLog in the same transaction. It does not change the password,
insert a credential, or bypass anything else. It refuses to run at all when
the database call fails (exit 5), rather than open a window it cannot audit.
Inside the window the target logs in (the account is back in
grace_period), opens /settings/webauthn, and enrolls a new credential,
which returns the account to mfa_required. If the window closes first, the
next login is refused again.
The window only lifts mfa_overdue. webauthn_status_for_user returns
mfa_required whenever the account holds a credential row, before it looks
at webauthn_required_after, and the script never touches
OperatorWebauthnCredential. If the lost authenticator's credential row
still exists, delete it first, then open the window:
DELETE FROM OperatorWebauthnCredential
WHERE user_id = <target user_id> AND credential_id = <lost credential_id>;Authorization comes from exactly one of two sources.
A different active admin runs, on the host:
./scripts/polaris-recover-admin.sh \
--target locked-out-admin \
--authorizing-user-id <second admin's AppUser.user_id> \
--window-minutes 15
# add --target=docker-stack to reach the compose Postgres; --dry-run to rehearseThe authorizer must be an active admin (exit 3 otherwise) and must not be
the target: self-authorization is refused with exit 3
(check_recover_admin_refuses_self_pairing in
polaris_checks/checks.py pins this). The
audit detail records authorized_by=user_id_<N>.
When no second admin exists, bind a recovery mnemonic to the account ahead
of time, with
scripts/polaris-generate-recovery-code.sh:
./scripts/polaris-generate-recovery-code.sh --bind-to your-admin > recovery-code.txt
# Print recovery-code.txt on paper, store the print in a safe, then:
shred -u recovery-code.txtThe script emits a 16-word mnemonic plus its SHA-256 digest, and with
--bind-to stores the digest in AppUser.recovery_code_hash. The server
never holds the words. Generating a new code for the same account replaces
the bound hash.
When the authenticator is lost, the operator SSHes to the host, deletes the
lost credential's row as above if one exists, and supplies the mnemonic on
stdin. The script accepts only - (stdin) for
--recovery-code; passing the words as an argument is rejected so they never
appear in ps output or /proc/<pid>/cmdline.
./scripts/polaris-recover-admin.sh --target your-admin --recovery-code -
# type the 16 words, then Ctrl+D
# or, non-interactively:
echo "<the 16 words>" | ./scripts/polaris-recover-admin.sh --target your-admin --recovery-code -The supplied words are lowercased, whitespace-collapsed, SHA-256 hashed, and
compared with the bound hash; a mismatch, or an account with no bound code,
exits 6. --recovery-code and --authorizing-user-id are mutually
exclusive. The audit detail records recovered_via=printed_recovery_code.
There is no web route for the recovery code; it is consumed on the host.
With shell access to the host you can still clear the requirement outright, which the deadline script treats as a sensitive, audited change:
./scripts/polaris-set-webauthn-deadline.sh --username your-admin --clear --force-clear
# log in with the password, enroll at /settings/webauthn, then set a deadline again:
./scripts/polaris-set-webauthn-deadline.sh --username your-admin --days 30Then bind a recovery mnemonic so the next loss does not need this path.
Lifting the requirement for one account (for example a legacy account that
cannot use WebAuthn) means setting AppUser.webauthn_required_after to NULL.
Use the script, which refuses without --force-clear and writes the audit
row:
./scripts/polaris-set-webauthn-deadline.sh --username specific-admin --clear --force-clearThe raw equivalent, if you must:
UPDATE AppUser
SET webauthn_required_after = NULL
WHERE username = 'specific-admin';An enrolled credential still gates login after the deadline is cleared
(mfa_required depends on the credential count, not the deadline); delete
the credential on /settings/webauthn as well to return the account to
password-only. The auditor role is exempt without any change. Do not do this
routinely; it removes the phishing-resistant layer. Record the reason in the
operator change log.
A hardware second factor raises the cost of coercion: an attacker can extort a password from an operator, but cannot extort physical possession of a key without physical presence. Second-admin pairing keeps the property that no single admin can authorize their own bypass. The deadline script's refusal to set a past deadline keeps a briefly compromised admin session from being used to lock out every other admin.
check_session_origin_hardening:webauthn_auth.pyreads all four policy knobs, offers ML-DSA-65, applies the user-verification policy in both ceremonies, and this document namesPOLARIS_WEBAUTHN_ATTESTATION.check_recover_admin_refuses_self_pairing: the recovery script compares the authorizer with the target.check_operator_scripts_validate_argv:--targetmust match theAppUserusername format before it reaches SQL.
All three live in polaris_checks/checks.py
and run with python -m polaris_checks.run.
polaris_web/webauthn_auth.py: the implementationscripts/polaris-recover-admin.sh: the emergency windowscripts/polaris-set-webauthn-deadline.sh: deadlines and--clearscripts/polaris-generate-recovery-code.sh: the printed mnemonic- SECRETS.md: the secrets matrix and rotation
- HARDENING.md: session and network-policy knobs that ship alongside the attestation policy