`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.

<Callout title="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.
</Callout>

## Quick start

<Steps>

<Step>
### Plug it

```typescript title="src/app.ts"
import { oke } from "okengine";
import { twoFactor } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(twoFactor());
```

</Step>

<Step>
### First-time enable (session required)

Wire Bearer on `createClient` (`auth.getToken` / `memorySession`) — calls take input only.

```typescript
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`.

</Step>

<Step>
### Sign-in then verify

```typescript
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`.

</Step>

</Steps>

## 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`, and
  `twoFactorDisable` return `Forbidden` with `active_2fa_challenge` if 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

1. `POST /auth/two-factor/step-up` — `{ code, purpose }` verifies the **current**
   method and grants a short-lived step-up (`enroll` | `change` | `disable`).
2. `POST /auth/two-factor/change-method` — `{ method, email? }` provisions the new
   method in a pending state (TOTP QR or email OTP). Requires step-up.
3. `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

<Accordions>
<Accordion title="twoFactorEnable returns Forbidden active_2fa_challenge">

Finish or wait out the login 2FA challenge first. Enrollment is blocked while
an unresolved challenge exists for that user.

</Accordion>
<Accordion title="twoFactorEnable returns Forbidden step_up_required">

2FA is already enabled. Call `twoFactorStepUp` with a valid current-method code
(`purpose: "enroll"`), then enable again.

</Accordion>
<Accordion title="twoFactorEnable returns AuthFailed unauthenticated">

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).

</Accordion>
<Accordion title="verify always fails">

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.

</Accordion>
</Accordions>

## Learn more

- [Passkey](/docs/plugins/passkey) — WebAuthn register / authenticate
- [Gate](/docs/elements/gate) — session + policies
- [Username](/docs/plugins/username) — first factor to enroll against
- Primary OTP sign-in (not a second factor)? See [OTP](/docs/plugins/otp) /
  [Magic link](/docs/plugins/magic-link)

## Next

<Cards>
  <Card title="Passkey" description="WebAuthn register and assert." href="/docs/plugins/passkey" />
  <Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
  <Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
</Cards>
