Email OTP
Send one-time email codes for sign-in, verification, reset, and email changes.
The email-OTP plugin emails the user a short numeric code that they type into the
current screen. Kernia stores the code on the core verification table, calls
your send_otp callback to deliver it, and consumes it atomically on verify — no
links, no redirects.
Installation
Add the plugin to your server
Pass email_otp() to your Kernia config and supply a send_otp callback. It
receives the recipient email, the generated code, and the purpose
("sign-in", "email-verification", or "forget-password") so you can tailor
the message.
import os
from kernia import KerniaOptions
from kernia.auth import init
from kernia.plugins.email_otp import email_otp
async def send_otp(email: str, otp: str, purpose: str) -> None:
subject = {
"sign-in": "Your sign-in code",
"email-verification": "Verify your email",
"forget-password": "Reset your password",
}.get(purpose, "Your code")
await email_client.send(
to=email,
subject=subject,
html=f"<p>Your code is <strong>{otp}</strong>. It expires in 5 minutes.</p>",
)
auth = init(KerniaOptions(
database=adapter,
secret=os.environ["KERNIA_SECRET"],
base_url=os.environ["KERNIA_BASE_URL"],
plugins=[email_otp()],
advanced={"email-otp": {"send_otp": send_otp}},
))Add the client plugin
Kernia speaks the Better Auth wire protocol, so the official JavaScript client
works unchanged. Add the emailOTP client plugin:
import { createAuthClient } from "better-auth/client";
import { emailOTPClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "/api/auth",
plugins: [emailOTPClient()],
});Usage
Sign in with an OTP
First request a code, then exchange it for a session.
// Send the code (purpose: "sign-in")
await authClient.emailOtp.sendVerificationOtp({
email: "user@example.com",
type: "sign-in",
});
// User types the code; verify it to create the session
await authClient.signIn.emailOtp({
email: "user@example.com",
otp: "123456",
});Verify an email address
await authClient.emailOtp.sendVerificationOtp({
email: "user@example.com",
type: "email-verification",
});
await authClient.emailOtp.verifyEmail({
email: "user@example.com",
otp: "123456",
});Reset a password with an OTP
await authClient.emailOtp.requestPasswordReset({
email: "user@example.com",
});
await authClient.emailOtp.resetPassword({
email: "user@example.com",
otp: "123456",
password: "new-password",
});Check a code without consuming it
checkVerificationOtp validates a code for a given purpose without spending it —
useful for a two-screen reset flow where you confirm the code before collecting a
new password.
await authClient.emailOtp.checkVerificationOtp({
email: "user@example.com",
type: "forget-password",
otp: "123456",
});Options
Configure under advanced["email-otp"]:
| Option | Type | Default | Description |
|---|---|---|---|
send_otp | async (email, otp, purpose) -> None | — | Required. Delivers the code. purpose is "sign-in", "email-verification", or "forget-password". |
otp_length | int | 6 | Number of digits in the generated code. |
expires_in | int | 300 | Code lifetime in seconds. |
allowed_attempts | int | 3 | Failed verifications allowed before the code is invalidated. |
disable_sign_up | bool | False | Reject sign-in codes for emails with no existing account. |
Schema
The plugin reuses the core verification table, keyed by
email-otp:<purpose>:<email>. No migration beyond the core schema is required.
Codes are single-use and consumed atomically on verify. After
allowed_attempts failed tries the code is discarded, so the user must request
a fresh one.