Better AuthのEmail OTP + DrizzleでメールOTP認証

Better Authを利用したサービスでGoogleログイン後に Email OTP(6桁のワンタイムパスワード)でメールアドレスの所有確認を挟みたいな〜という場面があり、 Better AuthemailOTP プラグインDrizzle Adapter を組み合わせて、OAuth ログイン後のメール認証を実装を楽できたのでメモとして残す。


昨今色々なサービスで使われているEmail OTP。OTP の生成・ハッシュ化・保存・検証・有効期限管理を自前で実装するのは結構面倒です(?)

developer.mozilla.org

Drizzle Adapter の schema mapping

Better Auth は内部で user, session, account, verification の4テーブルを使うが、Drizzle Adapter では 自分で定義したスキーマをそのまま渡す形になる。

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "mysql",
    schema: {
      user: usersTable,       // 自分の Drizzle テーブル定義
      session: sessionsTable,
      account: accountsTable,
      verification: verificationTable,
    },
  }),
});

これの何がいいかというと、Better Auth が期待するカラムさえ含んでいれば、独自カラムを自由に足せる。例えば user テーブルにアプリ固有の registrationStatus を追加して、メール認証前後でステータスを切り替えるような使い方ができる。

const usersTable = mysqlTable("user", {
  id: varchar("id", { length: 36 }).primaryKey(),
  name: varchar("name", { length: 255 }).notNull(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").default(false).notNull(), // Better Auth が管理
  // ↓ アプリ固有のカラムを自由に追加できる
  registrationStatus: mysqlEnum("registration_status", ["pending", "registered"])
    .default("registered").notNull(),
  // ...timestamps
});

verification テーブルには identifier(メールアドレス)、value(OTP ハッシュ)、expiresAt(有効期限)が入る。OTP を平文保存しないのは Better Auth が内部でやってくれている。

emailOTP プラグインの設定

サーバー側の設定は↓だけ。

import { emailOTP } from "better-auth/plugins";

export const auth = betterAuth({
  // ...database, socialProviders etc.
  plugins: [
    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        if (type === "email-verification") {
          // ここだけ自分で実装する(Resend, SendGrid, SES etc.)
          await sendEmail({ to: email, subject: "認証コード", body: otp });
        }
      },
      otpLength: 6,
      expiresIn: 10 * 60, // 10 minutes
    }),
  ],
});

sendVerificationOTP コールバックで Better Auth から OTP の平文を受け取り、好きなメール送信サービスに渡すだけ。type には "sign-in" / "email-verification" / "forget-password" が来るので、用途に応じてハンドルすればいい。

github.com

import { createAuthClient } from "better-auth/react";
import { emailOTPClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [emailOTPClient()],
});

// OTP 送信
await authClient.emailOtp.sendVerificationOtp({ email, type: "email-verification" });
// OTP 検証
await authClient.emailOtp.verifyEmail({ email, otp: code });

databaseHooks

Better Auth には databaseHooks という仕組みがあり、user.create.after などでユーザー作成直後にフック処理を差し込める。OAuth ログイン時に関連テーブルを初期化したり、ステータスを設定したりするのに便利。

ただし databaseHooks の after フックとトランザクションの関係はバージョンによって挙動が異なる。v1.4 系では after フックがトランザクション内で実行される。

github.com

v1.5.0-beta 以降では after フックがトランザクションのコミット後に実行されるよう修正が入っている

github.com

afterフック内で副作用のある処理を行う場合は、使用バージョンのトランザクション挙動を確認しておくのが無難。

まとめ

Better Auth + Drizzle は、スキーママイグレーションを Drizzle 側でコントロールしつつ認証機能を載せられるのが良い。emailOTP プラグインは設定が最小限で、OTP のライフサイクル管理を全部任せられる。Hono + Drizzle でも問題なく動いた。