Next.js 15 + better-authで作るパスワードレス認証システム
Next.js 15 + better-authで作るパスワードレス認証システム
🎯 この記事で作るもの
一緒に以下の機能を持つモダンなWebアプリケーションの認証システムを構築しましょう!
- ✅ Email OTP認証(パスワードレス)
- ✅ Google OAuth認証
- ✅ セッション管理とユーザー情報の取得
- ✅ プロフィール管理
- ✅ セキュリティ対策
完成すると、こんな認証フローが動きます:
- メールアドレス入力 → OTPコード送信 → 認証完了
- Googleアカウントで簡単ログイン
- ユーザー情報の自動取得・保存
🚀 開発環境の準備
ステップ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
にアクセスして、以下を試してください:
- メールアドレスを入力 → 「認証コードを送信」をクリック
- コンソールを確認 → ターミナルにOTPコードが表示されるはず
- OTPを入力 → 認証コードを入力してログイン
🎉 おめでとうございます! パスワードレス認証が動きました!
📝 Step 3: Google OAuth認証を追加
より便利な認証方法としてGoogle OAuthを追加しましょう。
Google Console設定
- Google Cloud Console にアクセス
- 新しいプロジェクトを作成または選択
- 「APIとサービス」→「認証情報」に移動
- 「認証情報を作成」→「OAuth 2.0 クライアント ID」を選択
- 以下の設定を行う:
-
承認済みの JavaScript 生成元:
http://localhost:3000
-
承認済みのリダイレクト URI:
http://localhost:3000/api/auth/callback/google
-
承認済みの JavaScript 生成元:
環境変数を更新
.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認証
- ✅ セッション管理
- ✅ プロフィール管理
- ✅ 認証状態の保持
次に実装できる機能
- アカウント削除機能
- メールアドレス変更
- 2要素認証(TOTP)
- 他のOAuthプロバイダー(GitHub、Discordなど)
- 管理者機能
本番環境への展開
-
環境変数を設定
- 本番用のデータベースURL
- 実際のOAuth認証情報
- メール送信サービスのAPIキー
-
ドメイン設定
-
trustedOrigins
を本番ドメインに更新 - OAuth設定のリダイレクトURLを更新
-
-
セキュリティ設定
- HTTPS必須
- セキュアなクッキー設定
- Rate limitingの実装
better-authは型安全で直感的なAPIを提供し、複雑な認証ロジックを簡潔に実装できます。この記事で学んだ基礎をもとに、あなたのプロジェクトに合わせてカスタマイズしてください!
参考リンク
この記事がお役に立てば幸いです!質問やフィードバックがあれば、コメントでお聞かせください。
Discussion