Two-factor
Official plugin — method-locked TOTP or email OTP after first-factor sign-in, with step-up enrollment and method change.
twoFactor() adds a second factor after password (or username) sign-in: RFC 6238
TOTP or email OTP. When 2FA is enabled, first-factor success withholds session
tokens and returns a method-locked challenge.
The one rule
Enable gate.auth, then .plug(twoFactor()). Login challenges lock the configured method (totp
or email_otp). Mid-challenge QR enrollment returns Forbidden. Method change needs step-up
verify → provision → confirm.
Quick start
Plug it
import { oke } from "okengine";
import { twoFactor } from "okengine/plugins";
export const app = oke({
name: "shop",
env: "dev",
gate: { auth: {} },
}).plug(twoFactor());First-time enable (session required)
Wire Bearer on createClient (auth.getToken / memorySession) — calls take input only.
const { data } = await api.auth.twoFactorEnable({});
// data.secret, data.otpauthUrl, data.recoveryCodes, data.method === "totp"POST /auth/two-factor/enable — writes oke_two_factor. Store recovery codes once.
Re-enrollment when 2FA is already enabled requires step-up first
(twoFactorStepUp). An active login challenge → Forbidden / active_2fa_challenge.
Sign-in then verify
const signIn = await api.auth.signInUsername({ username, password });
if ("twoFactorRequired" in signIn.data && signIn.data.twoFactorRequired) {
const { data } = await api.auth.twoFactorVerify({
challengeId: signIn.data.challengeId,
code: "123456",
});
// hybrid session tokens
}POST /auth/two-factor/verify takes { challengeId, code } — not a bare
userId. Missing challenge, method mismatch, or bad code → AuthFailed /
invalid_credentials.
Method lock
When a login challenge is issued, the active method is recorded on the pending challenge. Until that challenge is consumed or expires:
- Only codes for the locked method are accepted.
twoFactorEnable,twoFactorChangeMethod,twoFactorConfirmChange, andtwoFactorDisablereturnForbiddenwithactive_2fa_challengeif called for that user (including with an older Bearer session).
This closes the July–August 2026 method-switching bypass pattern (switch to TOTP enrollment mid email-OTP challenge without proving the current factor).
Step-up and method change
POST /auth/two-factor/step-up—{ code, purpose }verifies the current method and grants a short-lived step-up (enroll|change|disable).POST /auth/two-factor/change-method—{ method, email? }provisions the new method in a pending state (TOTP QR or email OTP). Requires step-up.POST /auth/two-factor/confirm-change—{ code }activates the new method and immediately invalidates the old secret / recovery codes.
Disable also requires step-up when 2FA is already enabled.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
secret | string | active* | HMAC secret (*from gate.auth) |
sessions | SessionStore | active* | Session store |
now | () => number | Date.now | Injectable clock |
factors | TwoFactorStore | new | Per-user method + TOTP / recovery |
issuer | string | "oke" | Label in otpauth:// URLs |
pending | PendingTwoFactorStore | active* | Login challenges |
stepUp | StepUpStore | active* | Privileged-op grants |
verifications | VerificationStore | active* | Email OTP as second factor (2fa:{userId}) |
exposeDevOtp | boolean | false | Return devOtp on email_otp challenge paths |
Surfaces
| Flow | Path | Gate |
|---|---|---|
auth.twoFactorEnable | POST /auth/two-factor/enable | session + bearer |
auth.twoFactorVerify | POST /auth/two-factor/verify | gate.public + otp rate |
auth.twoFactorStepUp | POST /auth/two-factor/step-up | session + bearer |
auth.twoFactorChangeMethod | POST /auth/two-factor/change-method | session + bearer |
auth.twoFactorConfirmChange | POST /auth/two-factor/confirm-change | session + bearer |
auth.twoFactorRequestEmailOtp | POST /auth/two-factor/request-email-otp | public + otp rate |
auth.twoFactorDisable | POST /auth/two-factor/disable | session + bearer |
Consequence: email/password and username sign-in return
{ twoFactorRequired, challengeId, method, userId } (no tokens) when the
account has 2FA enabled. Complete login only via twoFactorVerify.
Troubleshooting
Finish or wait out the login 2FA challenge first. Enrollment is blocked while an unresolved challenge exists for that user.
2FA is already enabled. Call twoFactorStepUp with a valid current-method code
(purpose: "enroll"), then enable again.
Wire a session into createClient (auth.getToken / memorySession) first —
enable is gated on Bearer. First-time enroll must happen before 2FA is
required on sign-in (or after a full session from a completed challenge).
Submit { challengeId, code } from the sign-in challenge response. TOTP must be
six digits (±1 window). Email OTP uses the code from the locked email_otp
challenge. A recovery code works once for TOTP, then is consumed.
Learn more
- Passkey — WebAuthn register / authenticate
- Gate — session + policies
- Username — first factor to enroll against
- Primary OTP sign-in (not a second factor)? See OTP / Magic link