Plugins

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.

auth.py
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:

auth-client.ts
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"]:

OptionTypeDefaultDescription
send_otpasync (email, otp, purpose) -> NoneRequired. Delivers the code. purpose is "sign-in", "email-verification", or "forget-password".
otp_lengthint6Number of digits in the generated code.
expires_inint300Code lifetime in seconds.
allowed_attemptsint3Failed verifications allowed before the code is invalidated.
disable_sign_upboolFalseReject 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.