🔐

Next.js 15 + better-authで作るパスワードレス認証システム

に公開

Next.js 15 + better-authで作るパスワードレス認証システム

🎯 この記事で作るもの

一緒に以下の機能を持つモダンなWebアプリケーションの認証システムを構築しましょう!

  • ✅ Email OTP認証(パスワードレス)
  • ✅ Google OAuth認証
  • ✅ セッション管理とユーザー情報の取得
  • ✅ プロフィール管理
  • ✅ セキュリティ対策

完成すると、こんな認証フローが動きます:

  1. メールアドレス入力 → OTPコード送信 → 認証完了
  2. Googleアカウントで簡単ログイン
  3. ユーザー情報の自動取得・保存

🚀 開発環境の準備

ステップ1:プロジェクトの作成

新しいNext.jsプロジェクトを作成しましょう:

# プロジェクトを作成
npx create-next-app@latest auth-demo --typescript --tailwind --eslint --app

# ディレクトリに移動
cd auth-demo

# better-authをインストール
npm install better-auth
npm install @prisma/client prisma
npm install jotai sonner lucide-react resend

ステップ2:環境変数の設定

.env.localファイルを作成して、以下を追加:

# データベース
DATABASE_URL="mysql://username:password@localhost:3306/auth_demo"

# Google OAuth(後で設定)
NEXT_PUBLIC_GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"

# メール送信(Resend)
RESEND_KEY="your_resend_api_key"

# アプリURL
NEXT_PUBLIC_APP_URL="http://localhost:3000"

💡 各サービスの設定方法は後ほど詳しく説明します!

ステップ3:データベースの準備

Prismaスキーマを設定しましょう。prisma/schema.prismaを作成:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

// better-auth必須テーブル
model User {
  id            String    @id
  name          String
  email         String    @unique
  emailVerified DateTime?
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  accounts Account[]
  sessions Session[]
  
  // アプリ固有のテーブル
  Profile         Profile[]

  @@map("user")
}

model Account {
  id                String  @id
  userId            String  @map("user_id")
  type              String
  provider          String
  providerAccountId String  @map("provider_account_id")
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?
  createdAt         DateTime @default(now()) @map("created_at")
  updatedAt         DateTime @default(now()) @updatedAt @map("updated_at")

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("account")
}

model Session {
  id           String   @id
  sessionToken String   @unique @map("session_token")
  userId       String   @map("user_id")
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt    DateTime @default(now()) @map("created_at")
  updatedAt    DateTime @default(now()) @updatedAt @map("updated_at")

  @@map("session")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verificationtoken")
}

// アプリ固有のテーブル
model Profile {
  id          String   @id @default(cuid())
  userId      String   @map("user_id")
  username    String   @unique
  displayName String   @map("display_name")
  bio         String?
  createdAt   DateTime @default(now()) @map("created_at")
  
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  
  @@map("profile")
}

データベースを作成:

npx prisma migrate dev --name init
npx prisma generate

📝 Step 1: 認証システムの基盤を作る

まずはbetter-authの設定ファイルを作成しましょう。ここが認証システムの心臓部です!

データベース接続を作る

src/lib/db.tsを作成:

import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma = globalForPrisma.prisma ?? new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

export const getPrismaClient = () => prisma

サーバーサイド認証設定を作る

src/lib/auth.tsを作成:

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { emailOTP, customSession } from "better-auth/plugins";
import { Resend } from "resend";
import { getPrismaClient } from "@/lib/db";

export const auth = betterAuth({
  // データベース設定
  database: prismaAdapter(getPrismaClient(), { provider: "mysql" }),
  
  // 信頼できるオリジンを設定
  trustedOrigins: [
    "http://localhost:3000", // 開発環境
    process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
  ],
  
  // プラグイン設定
  plugins: [
    // メール認証(OTP)
    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        console.log("📧 メール送信:", email, otp); // 開発時はコンソール出力
        
        // 本番環境では実際にメールを送信
        if (process.env.RESEND_KEY) {
          const resend = new Resend(process.env.RESEND_KEY);
          await resend.emails.send({
            from: "no-reply@yourapp.com",
            to: email,
            subject: "認証コード",
            html: `
              <h2>認証コード</h2>
              <p>以下のコードをアプリに入力してください:</p>
              <h1 style="color: #007bff; font-size: 32px;">${otp}</h1>
              <p>このコードは10分間有効です。</p>
            `
          });
        }
      },
    }),
    
    // カスタムセッション(プロフィール情報を含む)
    customSession(async ({ user, session }) => {
      const prisma = getPrismaClient();
      
      const userProfile = await prisma.user.findUnique({
        where: { id: user.id },
        include: {
          Profile: true
        },
      });

      const profile = userProfile?.Profile[0];

      return {
        userId: user.id,
        email: user.email,
        session,
        profileId: profile?.id,
        username: profile?.username,
        displayName: profile?.displayName,
        bio: profile?.bio,
      };
    }),
  ],
});

🔍 ここでのポイント:

  • 開発段階では console.log でOTPをコンソール出力
  • プロフィール情報をセッションに含めて、アプリ全体で使える状態に
  • trustedOriginsで信頼できるドメインを設定

クライアントサイド認証設定を作る

src/lib/auth-client.tsを作成:

import { createAuthClient } from "better-auth/client";
import { customSessionClient, emailOTPClient } from "better-auth/client/plugins";
import type { auth } from "@/lib/auth";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
  plugins: [
    customSessionClient<typeof auth>(),
    emailOTPClient(),
  ],
});

API Routeを設定する

src/app/api/auth/[...all]/route.tsを作成:

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth.handler);

これでbetter-authの基盤が完成です!🎉

📝 Step 2: ログイン画面を作る

実際に動く認証フォームを作成しましょう。

ログインページを作る

src/app/login/page.tsxを作成:

import LoginClient from "./LoginClient";

export default function LoginPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            アカウントにログイン
          </h2>
          <p className="mt-2 text-center text-sm text-gray-600">
            メールアドレスを入力して認証コードを受け取ってください
          </p>
        </div>
        <LoginClient />
      </div>
    </div>
  );
}

インタラクティブなログインコンポーネントを作る

src/app/login/LoginClient.tsxを作成:

"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { toast } from "sonner";

export default function LoginClient() {
  const [step, setStep] = useState<"email" | "otp">("email");
  const [email, setEmail] = useState("");
  const [otp, setOtp] = useState("");
  const [loading, setLoading] = useState(false);

  // メールアドレス送信
  const handleEmailSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      const { error } = await authClient.emailOtp.sendVerificationOtp({
        email,
        type: "sign-in",
      });

      if (error) {
        toast.error("認証コードの送信に失敗しました");
        return;
      }

      setStep("otp");
      toast.success(`${email} に認証コードを送信しました`);
    } catch (error) {
      toast.error("エラーが発生しました");
    } finally {
      setLoading(false);
    }
  };

  // OTP認証
  const handleOtpSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    try {
      const { data, error } = await authClient.emailOtp.verifyOtp({
        email,
        otp,
        callbackURL: "/dashboard",
      });

      if (error) {
        toast.error("認証に失敗しました。コードを確認してください");
        return;
      }

      // 認証成功 - リダイレクト
      window.location.href = "/dashboard";
    } catch (error) {
      toast.error("認証エラーが発生しました");
    } finally {
      setLoading(false);
    }
  };

  if (step === "email") {
    return (
      <form onSubmit={handleEmailSubmit} className="mt-8 space-y-6">
        <div>
          <label htmlFor="email" className="block text-sm font-medium text-gray-700">
            メールアドレス
          </label>
          <input
            id="email"
            type="email"
            required
            className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
            placeholder="your@example.com"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <button
          type="submit"
          disabled={loading || !email}
          className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
        >
          {loading ? "送信中..." : "認証コードを送信"}
        </button>
      </form>
    );
  }

  return (
    <form onSubmit={handleOtpSubmit} className="mt-8 space-y-6">
      <div>
        <label htmlFor="otp" className="block text-sm font-medium text-gray-700">
          認証コード
        </label>
        <input
          id="otp"
          type="text"
          required
          maxLength={6}
          className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-center text-2xl tracking-widest"
          placeholder="123456"
          value={otp}
          onChange={(e) => setOtp(e.target.value)}
        />
        <p className="mt-2 text-sm text-gray-500">
          {email} に送信された6桁のコードを入力してください
        </p>
      </div>
      <div className="flex space-x-4">
        <button
          type="button"
          onClick={() => setStep("email")}
          className="flex-1 py-2 px-4 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
        >
          戻る
        </button>
        <button
          type="submit"
          disabled={loading || !otp}
          className="flex-1 py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50"
        >
          {loading ? "認証中..." : "ログイン"}
        </button>
      </div>
    </form>
  );
}

動作テストをしよう!

開発サーバーを起動:

npm run dev

ブラウザで http://localhost:3000/login にアクセスして、以下を試してください:

  1. メールアドレスを入力 → 「認証コードを送信」をクリック
  2. コンソールを確認 → ターミナルにOTPコードが表示されるはず
  3. OTPを入力 → 認証コードを入力してログイン

🎉 おめでとうございます! パスワードレス認証が動きました!

📝 Step 3: Google OAuth認証を追加

より便利な認証方法としてGoogle OAuthを追加しましょう。

Google Console設定

  1. Google Cloud Console にアクセス
  2. 新しいプロジェクトを作成または選択
  3. 「APIとサービス」→「認証情報」に移動
  4. 「認証情報を作成」→「OAuth 2.0 クライアント ID」を選択
  5. 以下の設定を行う:
    • 承認済みの JavaScript 生成元: http://localhost:3000
    • 承認済みのリダイレクト URI: http://localhost:3000/api/auth/callback/google

環境変数を更新

.env.localにGoogle認証情報を追加:

NEXT_PUBLIC_GOOGLE_CLIENT_ID="your_actual_google_client_id"
GOOGLE_CLIENT_SECRET="your_actual_google_client_secret"

認証設定にGoogleを追加

src/lib/auth.tsを更新:

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { emailOTP, customSession } from "better-auth/plugins";
import { Resend } from "resend";
import { getPrismaClient } from "@/lib/db";

export const auth = betterAuth({
  database: prismaAdapter(getPrismaClient(), { provider: "mysql" }),
  trustedOrigins: [
    "http://localhost:3000",
    process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
  ],
  
  // Google OAuth設定を追加
  socialProviders: {
    google: {
      clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    },
  },
  
  plugins: [
    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        console.log("📧 メール送信:", email, otp);
        
        if (process.env.RESEND_KEY) {
          const resend = new Resend(process.env.RESEND_KEY);
          await resend.emails.send({
            from: "no-reply@yourapp.com",
            to: email,
            subject: "認証コード",
            html: `
              <h2>認証コード</h2>
              <p>以下のコードをアプリに入力してください:</p>
              <h1 style="color: #007bff; font-size: 32px;">${otp}</h1>
              <p>このコードは10分間有効です。</p>
            `
          });
        }
      },
    }),
    
    customSession(async ({ user, session }) => {
      const prisma = getPrismaClient();
      
      const userProfile = await prisma.user.findUnique({
        where: { id: user.id },
        include: {
          Profile: true
        },
      });

      const profile = userProfile?.Profile[0];

      return {
        userId: user.id,
        email: user.email,
        session,
        profileId: profile?.id,
        username: profile?.username,
        displayName: profile?.displayName,
        bio: profile?.bio,
      };
    }),
  ],
});

ログイン画面にGoogleボタンを追加

src/app/login/LoginClient.tsxを更新:

"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { toast } from "sonner";

export default function LoginClient() {
  const [step, setStep] = useState<"email" | "otp">("email");
  const [email, setEmail] = useState("");
  const [otp, setOtp] = useState("");
  const [loading, setLoading] = useState(false);

  // Google認証
  const handleGoogleSignIn = async () => {
    try {
      await authClient.signIn.social({
        provider: "google",
        callbackURL: "/dashboard",
      });
    } catch (error) {
      toast.error("Google認証に失敗しました");
    }
  };

  // ... 既存のコード ...

  if (step === "email") {
    return (
      <div className="mt-8 space-y-6">
        {/* Google認証ボタン */}
        <button
          type="button"
          onClick={handleGoogleSignIn}
          className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
        >
          <svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
            {/* Google アイコン */}
            <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
            <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
            <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
            <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
          </svg>
          Googleでログイン
        </button>

        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t border-gray-300" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="px-2 bg-gray-50 text-gray-500">または</span>
          </div>
        </div>

        {/* 既存のメール認証フォーム */}
        <form onSubmit={handleEmailSubmit} className="space-y-6">
          {/* ... 既存のコード ... */}
        </form>
      </div>
    );
  }

  // OTP入力フォームは既存のまま
  return (
    <form onSubmit={handleOtpSubmit} className="mt-8 space-y-6">
      {/* ... 既存のコード ... */}
    </form>
  );
}

🔐 セキュリティとベストプラクティス

CSRF保護

better-authには標準でCSRF保護が組み込まれています:

// 自動的にCSRFトークンが処理される
export const auth = betterAuth({
  // ... 設定
  advanced: {
    crossSubdomainCookies: {
      enabled: false, // 本番環境では必要に応じて設定
    },
  },
});

Rate Limiting

メール送信の制限を追加:

// src/lib/auth.ts
emailOTP({
  async sendVerificationOTP({ email, otp, type }) {
    // 簡単なレート制限(実装例)
    const rateLimitKey = `email_otp:${email}`;
    // Redisやメモリキャッシュでレート制限を実装
    
    console.log("📧 メール送信:", email, otp);
    
    if (process.env.RESEND_KEY) {
      const resend = new Resend(process.env.RESEND_KEY);
      await resend.emails.send({
        from: "no-reply@yourapp.com",
        to: email,
        subject: "認証コード",
        html: `
          <h2>認証コード</h2>
          <p>以下のコードをアプリに入力してください:</p>
          <h1 style="color: #007bff; font-size: 32px;">${otp}</h1>
          <p>このコードは10分間有効です。</p>
        `
      });
    }
  },
  // OTPの有効期限を設定
  expiresIn: 600, // 10分
}),

📚 まとめと次のステップ

🎉 おめでとうございます! better-authを使った完全な認証システムが完成しました。

完成した機能

  • ✅ パスワードレス認証(Email OTP)
  • ✅ Google OAuth認証
  • ✅ セッション管理
  • ✅ プロフィール管理
  • ✅ 認証状態の保持

次に実装できる機能

  1. アカウント削除機能
  2. メールアドレス変更
  3. 2要素認証(TOTP)
  4. 他のOAuthプロバイダー(GitHub、Discordなど)
  5. 管理者機能

本番環境への展開

  1. 環境変数を設定

    • 本番用のデータベースURL
    • 実際のOAuth認証情報
    • メール送信サービスのAPIキー
  2. ドメイン設定

    • trustedOriginsを本番ドメインに更新
    • OAuth設定のリダイレクトURLを更新
  3. セキュリティ設定

    • HTTPS必須
    • セキュアなクッキー設定
    • Rate limitingの実装

better-authは型安全で直感的なAPIを提供し、複雑な認証ロジックを簡潔に実装できます。この記事で学んだ基礎をもとに、あなたのプロジェクトに合わせてカスタマイズしてください!

参考リンク


この記事がお役に立てば幸いです!質問やフィードバックがあれば、コメントでお聞かせください。

Discussion