Next.js TodoアプリにNextAuth.js v5で認証認可を実装してみた
本記事のサマリ
Next.js 16のTodoアプリにNextAuth.js(Auth.js v5)を使って認証認可機能を実装した経験をまとめました。メール/パスワード認証とGoogle OAuth、そしてadmin/userのロールベース認可まで、実際のコードと共に段階的に解説していきます。
(どうやら長らくv5はbeta版のままのようですね…早くStable版がリリースされてほしいですね)
はじめに
Web アプリを作る上で、認証認可は避けて通れない要素の一つです。特にTodoアプリのような個人データを扱うアプリでは「誰が」「何を」操作できるかをきちんと制御する必要があります。
今回は NextAuth.js の最新版である Auth.js v5 を使って、シンプルなTodoアプリに本格的な認証認可機能を追加してみました。NextAuth.js v5 は従来の v4 から大幅にリニューアルされ、より柔軟で型安全な設計になっているのが特徴です。特にJWTベースのセッション管理やServer Actionsとの親和性の高さは、Next.js 16 のような最新の開発スタイルにもよくマッチしています。
実装する機能の概要
今回実装する認証認可システムは、実際のプロダクションでも使えるレベルを想定しています。認証方式はメール/パスワードとGoogle OAuthの2つを用意し、ユーザーロールはadminとuserの2段階で設計しました。
adminユーザーは全てのTodoを閲覧でき、さらにユーザー管理画面から他ユーザーの権限変更も可能です。一方でuserは自分が作成したTodoのみCRUD操作ができるという、よくあるマルチテナント的な権限設計になっています。
この設計により、チーム内でのタスク管理ツールとしても、個人的なTodo管理ツールとしても使えるアプリに仕上がります。
Phase 1: 依存パッケージのインストール
まずは必要なパッケージをインストールしていきましょう。NextAuth.js v5 は現在ベータ版なので、@beta タグを指定してインストールする必要があります。
cd app
bun add next-auth@beta @auth/prisma-adapter bcryptjs
bun add -d @types/bcryptjs
@auth/prisma-adapter は Prisma を使ったセッション管理のためのアダプターです。NextAuth.js v5 では様々なデータベースアダプターが用意されており、Prisma 以外にも MongoDB や Supabase なども選択できます。
bcryptjs はパスワードのハッシュ化に使用します。セキュリティ上、平文のパスワードをデータベースに保存するわけにはいかないので、必須のライブラリですね。
Phase 2: Prisma スキーマの更新
NextAuth.js を使用する際は、認証関連のテーブルをデータベースに追加する必要があります。公式ドキュメントを参考に、以下のモデルを prisma/schema.prisma に追加しました。
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String? // メール/パスワード認証用
role Role @default(USER)
accounts Account[]
sessions Session[]
todos Todo[] @relation("CreatedTodos")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
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?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
enum Role {
USER
ADMIN
}
既存のTodoモデルにも、作成者を示す createdById フィールドを追加します。
model Todo {
id String @id @default(cuid())
title String
description String?
completed Boolean @default(false)
assigneeId String?
assignee Assignee? @relation(fields: [assigneeId], references: [id], onDelete: SetNull)
createdById String? // 追加: 作成者
createdBy User? @relation("CreatedTodos", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
スキーマを更新したら、マイグレーションを実行します。
bunx prisma migrate dev --name add_auth_models
Phase 3: 環境変数の設定
NextAuth.js の設定には環境変数での設定が必要です。.env ファイルに以下を追加しましょう。
# Auth.js
AUTH_SECRET=your_generated_secret_here
AUTH_GOOGLE_ID=your_google_client_id
AUTH_GOOGLE_SECRET=your_google_client_secret
AUTH_SECRET は NextAuth.js がJWTトークンの署名に使用する重要な値です。十分に複雑な値を設定する必要があります。
# Auth.js CLI を使って生成
npx auth secret
# または OpenSSL で生成
openssl rand -base64 32
Google OAuth の認証情報は Google Cloud Console から取得できます。新しいプロジェクトを作成して「認証情報」から OAuth 2.0 クライアント ID を作成し、承認済みリダイレクト URI に http://localhost:3001/api/auth/callback/google を設定してください。
Phase 4: NextAuth.js の設定
NextAuth.js の設定ファイル lib/auth.ts を作成します。ここがこの実装の中核部分です。
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma'
declare module 'next-auth' {
interface User {
role?: Role
}
interface Session {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
role: Role
}
}
}
declare module '@auth/core/jwt' {
interface JWT {
id: string
role: Role
}
}
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
pages: {
signIn: '/auth/signin',
},
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
}),
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
})
if (!user || !user.password) {
return null
}
const isPasswordValid = await bcrypt.compare(
credentials.password as string,
user.password
)
if (!isPasswordValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id!
token.role = user.role || 'USER'
}
return token
},
async session({ session, token }) {
if (token) {
session.user.id = token.id
session.user.role = token.role
}
return session
},
},
})
ここではJWTベースのセッション管理を採用しています。データベースへの問い合わせが不要になるため、パフォーマンス面でのメリットがありますね。
API ルートも設定します。app/api/auth/[...nextauth]/route.ts を作成してください。
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers
Phase 5: 認証 UI の作成
認証画面を作成していきます。まずはログインページから。
// app/auth/signin/page.tsx
import { auth, signIn } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function SignInPage() {
const session = await auth()
if (session?.user) {
redirect('/')
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md w-full space-y-8 p-8">
<h2 className="text-2xl font-bold text-center">ログイン</h2>
<form action={async (formData) => {
'use server'
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: '/',
})
}}>
<div className="space-y-4">
<input
name="email"
type="email"
required
placeholder="メールアドレス"
className="w-full px-3 py-2 border rounded-md"
/>
<input
name="password"
type="password"
required
placeholder="パスワード"
className="w-full px-3 py-2 border rounded-md"
/>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded-md hover:bg-blue-600"
>
ログイン
</button>
</div>
</form>
<form action={async () => {
'use server'
await signIn('google', { redirectTo: '/' })
}}>
<button
type="submit"
className="w-full bg-red-500 text-white py-2 rounded-md hover:bg-red-600"
>
Googleでログイン
</button>
</form>
</div>
</div>
)
}
Server Actions を使って認証処理を実装しているのがポイントです。NextAuth.js v5 は Next.js の最新機能とよく統合されているなと感じます👍
新規登録機能も同様に実装します。パスワードのハッシュ化処理を忘れずに行うことが重要です。
Phase 6: Server Actions への認可追加
既存のTodo操作のServer Actionsに認可処理を追加していきます。まずは認可用のヘルパー関数を作成しました。
// lib/auth-utils.ts
import { auth } from './auth'
import { prisma } from './prisma'
export async function getCurrentUser() {
const session = await auth()
return session?.user ?? null
}
export async function requireAuth() {
const user = await getCurrentUser()
if (!user) {
throw new Error('認証が必要です')
}
return user
}
export async function requireAdmin() {
const user = await requireAuth()
if (user.role !== 'ADMIN') {
throw new Error('管理者権限が必要です')
}
return user
}
export async function canEditTodo(todoId: string) {
const user = await requireAuth()
if (user.role === 'ADMIN') {
return true
}
const todo = await prisma.todo.findUnique({
where: { id: todoId },
select: { createdById: true },
})
if (!todo || todo.createdById !== user.id) {
throw new Error('この Todo を編集する権限がありません')
}
return true
}
これらのヘルパー関数を使って、Todo操作のServer Actionsを更新していきます。
// actions/todo.ts
export async function getTodos() {
const user = await getCurrentUser()
if (!user) {
return []
}
// Adminは全Todoを閲覧可能
if (user.role === 'ADMIN') {
return await prisma.todo.findMany({
include: {
assignee: true,
createdBy: {
select: { id: true, name: true, email: true },
},
},
orderBy: { createdAt: 'desc' },
})
}
// Userは自分が作成したTodoのみ
return await prisma.todo.findMany({
where: { createdById: user.id },
include: {
assignee: true,
createdBy: {
select: { id: true, name: true, email: true },
},
},
orderBy: { createdAt: 'desc' },
})
}
export async function createTodo(formData: FormData) {
const user = await requireAuth()
const title = formData.get('title') as string
const description = formData.get('description') as string | null
await prisma.todo.create({
data: {
title,
description: description || null,
createdById: user.id, // 作成者を自動設定
},
})
revalidatePath('/')
}
export async function updateTodo(id: string, formData: FormData) {
await canEditTodo(id) // 編集権限チェック
const title = formData.get('title') as string
const completed = formData.get('completed') === 'on'
await prisma.todo.update({
where: { id },
data: { title, completed },
})
revalidatePath('/')
}
この認可設計により、userは自分のTodoだけを操作でき、adminは全体を管理できるという、実用的な権限制御が実現できました✨
Phase 7: Admin ユーザー管理機能
Admin専用のユーザー管理機能を実装します。これにより、管理者がユーザーのロールを変更したり、ユーザーを削除したりできるようになります。
// actions/user.ts
export async function getUsers() {
await requireAdmin()
return await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
})
}
export async function updateUserRole(userId: string, role: Role) {
const admin = await requireAdmin()
// 自分自身の権限は変更不可
if (admin.id === userId) {
throw new Error('自分自身の権限は変更できません')
}
await prisma.user.update({
where: { id: userId },
data: { role },
})
revalidatePath('/admin/users')
}
自分自身の権限変更を防ぐ処理を入れているのがポイントです。これがないと、うっかり自分をuser権限にして管理機能にアクセスできなくなってしまう可能性があります。
Phase 8: Middleware によるルート保護
最後に、Middleware を使ってルートレベルでの認証・認可制御を実装します。
// middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
const publicPaths = ['/auth/signin', '/auth/signup']
export default auth((req) => {
const { pathname } = req.nextUrl
// 認証不要なパスはスキップ
if (publicPaths.some((path) => pathname.startsWith(path))) {
return NextResponse.next()
}
// 未認証の場合はログインページへリダイレクト
if (!req.auth) {
const signInUrl = new URL('/auth/signin', req.url)
signInUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(signInUrl)
}
// Adminページは管理者のみアクセス可能
if (pathname.startsWith('/admin') && req.auth.user.role !== 'ADMIN') {
return NextResponse.redirect(new URL('/', req.url))
}
return NextResponse.next()
})
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Middleware での制御は多層防御の一環として重要です。Server Actions での認可チェックと併せて、よりセキュアなアプリになります。
セキュリティ考慮事項
今回の実装でセキュリティ面で気をつけた点をいくつか紹介します。
パスワードのハッシュ化は bcryptjs を使用し、cost factor は 10 に設定しました。これは現在のハードウェア性能において適切なバランスと考えています。JWT トークンの署名には十分に長い AUTH_SECRET を使用し、本番環境では必ず環境変数で管理するようにしてください。
また、認可処理については多層防御を意識しました。Middlewareでのルートレベル制御、Server Actionsでの操作レベル制御、そしてデータベースクエリでのユーザースコープ制御を組み合わせることで、複数の段階でセキュリティを確保しています。
CSRF 対策については、Server Actions が自動的にCSRFトークンで保護されるため、特別な実装は不要でした。これもNext.jsの最新機能を使うメリットの一つですね。
まとめ
NextAuth.js v5 を使った認証認可の実装は、思った以上にスムーズに進められました。特にJWTベースのセッション管理やServer Actionsとの親和性の高さは、開発体験を大きく向上させてくれます。
今回実装した機能は基本的なものですが、実際のプロダクションでも十分に使えるレベルの認証認可システムになっていると思います。ロールベースの権限制御により、個人向けアプリからチーム向けアプリまで幅広く対応できます。
NextAuth.js v5 はまだベータ版ですが、安定版がリリースされれば、さらに多くのプロジェクトで採用されそうな予感がします。認証認可の実装で悩んでいる方は、ぜひ試してみてください!
株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion