Two Factor
Add TOTP and backup-code challenges after password or username sign-in.
The two-factor plugin adds a second step to credential sign-in. When a user with
2FA enabled signs in with email/password or username/password, Kernia withholds
the session and returns { twoFactorRedirect: true }. The user must verify a
TOTP code, an emailed OTP, or a backup code before a real session is issued.
Installation
Add the plugin to your server
Pass two_factor() to your Kernia config. The plugin requires pyotp
(installed via the two-factor extra). Configuration — issuer, TOTP settings,
and the optional OTP sender — lives under advanced["two-factor"].
import os
from kernia import KerniaOptions
from kernia.auth import init
from kernia.plugins.two_factor import two_factor
async def send_otp(data: dict, ctx) -> None:
# data = {"user": <user row>, "otp": "123456"}
await email_client.send(
to=data["user"]["email"],
subject="Your verification code",
html=f"<p>Your code is <strong>{data['otp']}</strong></p>",
)
auth = init(KerniaOptions(
database=adapter,
secret=os.environ["KERNIA_SECRET"],
base_url=os.environ["KERNIA_BASE_URL"],
plugins=[two_factor()],
advanced={
"two-factor": {
"issuer": "Acme",
"otp_options": {"send_otp": send_otp},
"totp_options": {"digits": 6, "period": 30},
}
},
))Add the client plugin
Kernia speaks the Better Auth wire protocol, so the official JavaScript client
works unchanged. Add the twoFactorClient plugin and handle the redirect that
signals a second factor is required.
import { createAuthClient } from "better-auth/client";
import { twoFactorClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "/api/auth",
plugins: [
twoFactorClient({
onTwoFactorRedirect({ twoFactorMethods }) {
// e.g. ["totp", "otp"] — route the user to your 2FA screen
window.location.href = "/two-factor";
},
}),
],
});Usage
Enable two factor
Enabling requires the user's password. Kernia returns a totpURI to render as a
QR code in an authenticator app, plus backup codes.
const { data } = await authClient.twoFactor.enable({
password: "the-password",
});
// data.totpURI -> render as QR; data.backupCodes -> show onceVerify during setup or sign-in
After credential sign-in returns twoFactorRedirect, collect the code and
verify it. trustDevice skips the second factor on this browser for future
sign-ins.
await authClient.twoFactor.verifyTotp({
code: "123456",
trustDevice: true,
});Email OTP as a second factor
If you configured otp_options.send_otp, the user can request a one-time code
instead of using their authenticator app.
await authClient.twoFactor.sendOtp(); // delivers via your send_otp
await authClient.twoFactor.verifyOtp({ code: "123456" });Backup codes
// Regenerate (requires password) — show the new set once
await authClient.twoFactor.generateBackupCodes({ password: "the-password" });
// Use one to clear a 2FA challenge when the device is unavailable
await authClient.twoFactor.verifyBackupCode({ code: "a1b2-c3d4" });Disable two factor
await authClient.twoFactor.disable({ password: "the-password" });Options
Configure under advanced["two-factor"]:
| Option | Type | Default | Description |
|---|---|---|---|
issuer | str | app name | Label shown in authenticator apps. |
otp_options.send_otp | async ({user, otp}, ctx) -> None | — | Delivers the email/SMS OTP second factor. Required to use the OTP flow. |
totp_options | dict | {digits: 6, period: 30} | TOTP algorithm settings (digits, period, disable). |
skip_verification_on_enable | bool | False | Enable 2FA immediately without a verification step. |
trust_device_max_age | int | 2592000 | Trust-device cookie lifetime in seconds (30 days). |
two_factor_cookie_max_age | int | 600 | Lifetime of the pending-challenge cookie in seconds. |
Schema
The plugin adds a twoFactor table and supporting tables, and extends user:
twoFactor.idtwoFactor.userIdtwoFactor.secrettwoFactor.backupCodestwoFactor.verifieduser.twoFactorEnabledIt also adds twoFactorConfirmation and twoFactorBackupCode tables. Run
kernia generate after enabling the plugin.
The sign-in gate is an after-hook on /sign-in/email, /sign-in/username, and
/sign-in/phone-number: it deletes the provisional session and returns
twoFactorRedirect. Always handle onTwoFactorRedirect on the client, or
sign-in will appear to silently fail for 2FA users.