GitHub
08/16/2026, 7:12 AMPOST /api/v1/login answers with a challenge instead of a token, and sets no cookies:
{"mfa_required": true, "challenge": "…", "methods": ["totp", "webauthn", "recovery"]}
The session is minted only by the second request — POST /api/v1/login/mfa with a TOTP or recovery code, or the WebAuthn `begin`/`finish` pair. Both steps sit behind the same per-IP login rate limiter, and every rejected factor is audit-logged.
Enforcement
service.mfaRequired (--mfa-required, SERVICE_MFA_REQUIRED, default off) makes a second factor mandatory. A user who has none is not locked out: the login returns an enrollment challenge, they scan a QR code and confirm a code, and the session arrives together with their recovery codes — still nothing issued until a factor exists and is proven. While it is on, the last remaining factor on an account cannot be removed.
| Setting | Flag | Environment variable | Default |
| ------------------- | -------------- | -------------------- | --------------- |
| service.mfaRequired | --mfa-required | SERVICE_MFA_REQUIRED | false |
| service.mfaIssuer | --mfa-issuer | SERVICE_MFA_ISSUER | osctrl (<host>) |
| service.mfaRPID | --mfa-rpid | SERVICE_MFA_RPID | service.host |
| service.mfaOrigins | --mfa-origins | SERVICE_MFA_ORIGINS | https://<rp id> |
WebAuthn needs a Relying Party ID and the exact origins the SPA is served from. When neither can be resolved the relying party stays nil: TOTP and recovery codes keep working and the SPA hides passkey registration.
Enrollment
Users enroll themselves from Profile → Two-factor authentication: set up an authenticator app, register passkeys and security keys, regenerate recovery codes. Removing a factor or regenerating codes requires re-entering the account password, so a hijacked session cannot quietly strip protection off an account. Recovery codes are displayed once, at generation.
Security properties
• TOTP time steps are burned on use, so a shoulder-surfed code cannot be replayed inside its 30s window; drift tolerance is ±1 step.
• Challenges are single-use and expire after five minutes; the winner of a race is the request that claims the row.
• Recovery codes are consumed atomically (conditional update), so the same code cannot satisfy two concurrent logins.
• WebAuthn ceremony state lives server-side on the challenge row — the browser never carries it.
• TOTP secrets are never returned after enrollment completes; recovery codes are stored as SHA-256 (they are ~99 bits of machine-generated randomness, and bcrypt on ten of them would put seconds on the login path).
• Second-factor failures return one deliberately vague error, so nothing reveals which half of the check failed.
• Deleting a user removes their factors, so a recreated username cannot inherit an old secret or key.
Endpoints
Pre-auth (login rate limiter): POST /api/v1/login/mfa, /login/mfa/webauthn/begin, /login/mfa/webauthn/finish, /login/mfa/enroll/begin, /login/mfa/enroll/finish
Authenticated (own account): GET /api/v1/mfa, POST|DELETE /api/v1/mfa/totp, POST /api/v1/mfa/totp/verify, POST /api/v1/mfa/recovery, POST /api/v1/mfa/webauthn, POST /api/v1/mfa/webauthn/verify, DELETE /api/v1/mfa/webauthn/{id}
Changes
• pkg/mfa (new) — TOTP on stdlib crypto (RFC 6238: HMAC-SHA1, 6 digits, 30s), recovery codes, WebAuthn wrapper over go-webauthn, and the manager owning four auto-migrated tables: user_mfa_totp, user_mfa_credentials, user_mfa_recovery_codes, user_mfa_challenges. QR codes render server-side as PNG data URIs, so the SPA needs no QR library.
• cmd/api/handlers — mfa_login.go (second factor + forced enrollment) and mfa.go (self-service enrollment). Session issuing was pulled out of LoginHandler into a single issueSession helper so the MFA paths cannot drift from the password-only path.
• cmd/api/main.go — builds the manager and, when an RP ID and origin resolve, the WebAuthn relying party; registers the routes.
• pkg/config — the four service.mfa* values, flags and environment variables.
• frontend/ — api/mfa.ts (client plus the base64url ↔️ ArrayBuffer plumbing for the browser ceremonies), a second step on the login page (code, security key, recovery code, forced enrollment), and the profile panel.
• Docs — an MFA section in docs/auth-providers.md covering the flow, WebAuthn RP ID/origins and the DB reset for locked-out users; annotated sample YAMLs; regenerated OpenAPI spec; CHANGELOG entry.
New dependencies: <http://github.com/go-webauthn/webauthn|github.com/go-webauthn/webauthn>, <http://github.com/skip2/go-qrcode|github.com/skip2/go-qrcode>.
Testing
• go build ./..., go test ./..., go vet ./..., make openapi-check — pass.
• Frontend: 233 tests pass, tsc clean.
• New coverage: RFC 6238 vectors, drift and replay, enrollment lifecycle, re-enrollment after removal, challenge single-use and expiry, recovery-code consumption, the full two-step login (TOTP, recovery, forced enrollment, service-account exemption), and the SPA's second-factor screen.
Self-review caught one real bug before it shipped: gorm.Model soft-deletes leave the row in the unique index, so removing a factor and enrolling again would have collided. Fixed, with regression tests for both TOTP and credentials.
Not in this PR
• Registering a passkey during forced enrollment (that step is TOTP-only; keys can be added from the profile afterwards).
• An admin-facing "reset this user's MFA" action — recovery today is the documented SQL.
jmpsec/osctrlGitHub
08/16/2026, 8:23 AM