Supabase Authで認証・認可を実装する
Supabase Auth を使うと、自分で作るのは非常に大変なログイン機能を簡単かつ安全に実装できます。
ここでは Supabase Auth を使って、メールアドレスとパスワードによる認証を実装し、ログインユーザーだけがアクセスできるページを作ります。
また、その過程で「認証」と「認可」の違いについても学びます。
このページで学ぶこと
- 認証と認可の違いがわかる
- Supabase Auth でメール認証を実装できる
- ログイン済みユーザーのみアクセスできるページを作れる
認証と認可の違い
| 用語 | 意味 | 例 |
|---|---|---|
| 認証(Authentication) | 「あなたは誰?」を確認する | ログイン |
| 認可(Authorization) | 「あなたに権限はある?」を確認する | 自分のTodoしか編集できない |
Supabase Auth のセットアップ
ステップ1: パッケージをインストール
npm install @supabase/ssr @supabase/supabase-js
@supabase/supabase-jsはすでにインストール済みのはずです。
ステップ2: Supabase クライアントを2種類用意する
Supabase SSR では「ブラウザ用」と「サーバー用」の2種類のクライアントを使います。
ブラウザ用(フロントエンドのコンポーネントで使う)
// lib/supabase/client.js
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY // ブラウザ公開OKのキーを使う
)
}サーバー用(APIルートで使う)
// lib/supabase/server.js
// サーバー側(Server Component・Route Handler)で使う Supabase クライアントを作る。
// ブラウザ版(client.js)と違い、cookie を通じてセッションを管理する。
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
// Next.js のサーバー側 cookie ストアを取得する(Next.js 16 では await が必要)
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
{
cookies: {
// ブラウザが送ってきた cookie をすべて Supabase に渡す
getAll: () => cookieStore.getAll(),
// Supabase がセッションを更新したとき、新しい cookie を書き込もうとする。
// ただし Server Component は「読み取り専用」なので cookie の書き込みができず
// Next.js が例外を投げる。それでも動作は問題ないので try/catch で握りつぶす。
// ※ セッションの更新(cookie への書き込み)は proxy.js が担当している。
// ※ 第 2 引数の _headers は proxy/middleware 向けの CDN キャッシュ制御用なので
// Server Component では使わない(_ で始めることで「未使用」と明示)。
setAll: (cookiesToSet, _headers) => {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component からの呼び出しでは書き込みが禁止されているため無視する
}
},
},
}
)
}ステップ3: proxy.js を作成する
Supabase Auth ではセッション(ログイン状態)をCookieで管理します。
proxy.js で未ログインユーザーを入口で止めると、ページ全体を安全に保護できます。
// proxy.js(プロジェクトのルートに置く)
// ─────────────────────────────────────────────────────────────────────────────
// このファイルは任意のページへのアクセスの「手前」で実行される門番のような役割を持つ。
// ここでログイン状態をチェックし、未ログインなら /login へ、
// ログイン済みで /login に来たならホームへ、それぞれリダイレクトする。
// ─────────────────────────────────────────────────────────────────────────────
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
export async function proxy(request) {
// デフォルトのレスポンス(「そのままページを表示する」)を用意しておく。
// cookie を書き換えた場合にこの変数を上書きするため let にしている。
let response = NextResponse.next({ request })
const pathname = request.nextUrl.pathname
// ログインや登録ページは未ログインでも見せたいので「認証ページ」として区別する
const isAuthRoute = pathname === '/login' || pathname === '/signup'
// ── Supabase クライアントを作成 ─────────────────────────────────────────────
// サーバー側(この proxy)からは cookie を通じてセッション情報をやり取りする。
// getAll:ブラウザが送ってきた cookie をすべて読み取る
// setAll:Supabase がセッションを更新したとき、新しい cookie をレスポンスに書き込む
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet, headers) => {
// ① リクエスト側にも新しい cookie を反映する。
// こうしないと、同じリクエスト内で getUser() を呼んだとき
// 古いトークンを読んでしまう。
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
// ② 新しい cookie を含むレスポンスを作り直す
response = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
// ③ CDN(CloudFront など)がこのレスポンスをキャッシュして
// 別のユーザーに使い回さないよう、Cache-Control などを設定する
Object.entries(headers).forEach(([key, value]) =>
response.headers.set(key, value)
)
},
},
}
)
// Supabase サーバーに問い合わせてログイン中のユーザーを取得する。
// getSession() はローカルの cookie だけを見るため改ざんリスクがある。
// getUser() はサーバーで検証するのでこちらを使う(セキュアなチェック)。
const { data: { user } } = await supabase.auth.getUser()
// 未ログインで、ログイン・登録ページ以外にアクセスしようとしたらログインへ飛ばす。
// ?from=元のパス を付けることで、ログイン後に元のページへ戻れるようにする。
if (!user && !isAuthRoute) {
const loginUrl = request.nextUrl.clone()
loginUrl.pathname = '/login'
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
// すでにログイン済みなのに /login や /signup を開こうとしたらホームへ飛ばす。
if (user && isAuthRoute) {
const homeUrl = request.nextUrl.clone()
homeUrl.pathname = '/'
homeUrl.search = ''
return NextResponse.redirect(homeUrl)
}
return response
}
// matcher:この proxy を動かすパスを指定する正規表現。
// _next/static(JS・CSSなどの静的ファイル)、_next/image(画像最適化)、
// favicon.ico、api(APIルート)は除外する。
// ※ api は除外しているが、APIルートで認証が必要な場合は
// 各ルートのハンドラ内で個別にチェックすること(proxy だけに頼らない)。
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
}
ステップ4: Supabase ダッシュボードでメール確認を無効化する(開発時)
デフォルトではサインアップ時に確認メールが送られます。
開発中は毎回メールを確認するのが面倒なので、一時的に無効化しましょう。
- Supabase ダッシュボード → Authentication → Sign In / Providers
- 「User Signups」の「Confirm email」をオフにする
本番環境では必ず有効に戻してください。
やってみよう
ステップ1: サインアップページを作る
// app/signup/page.js
'use client'
import { useState } from 'react'
import { createClient } from '../../lib/supabase/client'
import Link from 'next/link'
export default function SignupPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const [pending, setPending] = useState(false) // 送信中かどうか(二重送信防止)
const supabase = createClient()
const handleSignup = async (e) => {
e.preventDefault() // ブラウザのデフォルトのページリロードをキャンセル
setPending(true)
setMessage('')
const { data, error } = await supabase.auth.signUp({ email, password })
if (error) {
setMessage(`エラー: ${error.message}`)
setPending(false)
return
}
// Supabase の「メール確認」機能が有効か無効かで挙動が変わる。
//
// ① メール確認が有効(デフォルト)の場合:
// 登録メールが届き、ユーザーはリンクをクリックして本登録する。
// この場合 data.session は null になる(まだログインしていない)。
//
// ② メール確認が無効(開発中にオフにした場合)の場合:
// 即座にログイン済み状態になり data.session にセッションが入る。
//
// ③ すでに同じメールで登録済みのユーザーが signUp を呼んだ場合:
// エラーを返さず、identities(連携アカウント一覧)が空配列で返ってくる。
// これは「そのメールが存在するかどうか」を攻撃者に教えないための Supabase の仕様。
// → どの場合も同じ「確認メールを送りました」メッセージにして情報を漏らさない。
if (data.user && data.user.identities && data.user.identities.length === 0) {
// ③ のケース(既存ユーザー)。本人には確認メールが届かないが同じ文言を返す
setMessage('確認メールを送信しました。受信箱をご確認ください。')
} else if (data.session) {
// ② のケース(メール確認なし・即ログイン)
setMessage('登録が完了しました。ログインページからログインしてください。')
} else {
// ① のケース(メール確認あり・通常)
setMessage('確認メールを送信しました。受信箱をご確認ください。')
}
setPending(false)
}
// onSubmit を使うことで Enter キーでも送信できる(button onClick では動かない)
return (
<form onSubmit={handleSignup} style={{ padding: '32px', maxWidth: '400px' }}>
<h1>新規登録</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メールアドレス"
// autoComplete="email":パスワードマネージャーやブラウザの自動入力が
// 「メールアドレス」として保存した値をここに補完してくれる
autoComplete="email"
required
style={{ display: 'block', width: '100%', padding: '8px', marginBottom: '8px' }}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="パスワード(6文字以上)"
// autoComplete="new-password":「新しいパスワードを作る場面」であることをブラウザに伝える。
// ログインページの "current-password"(既存パスワードの補完)と区別することで、
// ブラウザが「パスワードを保存しますか?」と正しく提案してくれる。
// また一部のブラウザはこの値を見てランダムパスワードの自動生成を提案する。
autoComplete="new-password"
minLength={6}
required
style={{ display: 'block', width: '100%', padding: '8px', marginBottom: '16px' }}
/>
{/* disabled で送信中の二重クリックを防ぐ */}
<button type="submit" disabled={pending} style={{ width: '100%', padding: '8px' }}>
{pending ? '送信中…' : '登録する'}
</button>
{message && <p style={{ marginTop: '16px' }}>{message}</p>}
{/* form の内側に置くことでフォームの左端・幅に揃う */}
<Link href="/login" style={{ display: 'block', marginTop: '16px', textAlign: 'center' }}>
ログインはこちら
</Link>
</form>
)
}ステップ2: ログインページを作る
// app/login/page.js
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { createClient } from '../../lib/supabase/client'
import Link from 'next/link'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [pending, setPending] = useState(false) // 送信中かどうか(二重送信防止)
const router = useRouter()
const searchParams = useSearchParams() // ?from=/xxx のようなクエリを読む
const supabase = createClient()
const handleLogin = async (e) => {
e.preventDefault() // ブラウザのデフォルトのページリロードをキャンセル
setPending(true)
setError('')
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
// 実際のエラー内容(「メールが存在しない」など)を返すと
// 攻撃者にヒントを与えるため、あえて曖昧なメッセージにする
setError('メールアドレスまたはパスワードが間違っています')
setPending(false)
return
}
// ログイン成功後の遷移先を決める。
// ?from=/dashboard のようにリダイレクト元が渡されていればそこへ、なければホームへ。
// from が / で始まらない値(外部 URL など)は無視して安全にする(オープンリダイレクト対策)。
const from = searchParams.get('from')
const dest = from && from.startsWith('/') ? from : '/'
router.replace(dest)
// router.refresh() でサーバー側のキャッシュを破棄し、
// ログイン直後にサーバーコンポーネントが新しい cookie(セッション)を読めるようにする。
router.refresh()
}
// onSubmit を使うことで Enter キーでも送信できる(button onClick では動かない)
return (
<form onSubmit={handleLogin} style={{ padding: '32px', maxWidth: '400px' }}>
<h1>ログイン</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="メールアドレス"
// autoComplete="email":パスワードマネージャーやブラウザの自動入力が
// 「メールアドレス」として保存した値をここに補完してくれる
autoComplete="email"
required
style={{ display: 'block', width: '100%', padding: '8px', marginBottom: '8px' }}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="パスワード"
// autoComplete="current-password":「今使っているパスワード」の補完を促す。
// ブラウザやパスワードマネージャーがこのフィールドを正しく認識して保存・入力してくれる。
// 新規登録ページでは "new-password" を使うのと区別することが重要。
autoComplete="current-password"
required
style={{ display: 'block', width: '100%', padding: '8px', marginBottom: '16px' }}
/>
{error && <p style={{ color: 'red' }}>{error}</p>}
{/* disabled で送信中の二重クリックを防ぐ */}
<button type="submit" disabled={pending} style={{ width: '100%', padding: '8px' }}>
{pending ? 'ログイン中…' : 'ログイン'}
</button>
{/* form の内側に置くことでフォームの左端・幅に揃う */}
<Link href="/signup" style={{ display: 'block', marginTop: '16px', textAlign: 'center' }}>
新規登録はこちら
</Link>
</form>
)
}ステップ3: ログイン状態をサーバー側で確認してページを保護する
// app/page.js
import { redirect } from 'next/navigation'
import { createClient } from '../lib/supabase/server'
export default async function HomePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// 未ログインなら、画面を描画する前にログインページへ飛ばす
if (!user) {
redirect('/login')
}
return (
<div style={{ padding: '32px' }}>
<h1>ホーム</h1>
<p>ようこそ、{user.email} さん</p>
<p>ここにログイン後だけ見せたい内容を置きます。</p>
</div>
)
}このページでは認証判定をクライアント側で行いません。
認証はmiddleware.jsとサーバーコンポーネントのredirect()だけで行い、ブラウザ側のコードは見た目の表示だけに使います。
クライアント側の認証判定をしない理由
ブラウザで動くコードは、開発者ツールを通じてユーザーが見たり変更したりできるので、認証の本体には使いません。
クライアント側で supabase.auth.getUser() を呼ぶと表示の切り替えには便利ですが、セキュリティの保証にはなりません。
この章では次の役割分担にします。
middleware.js: 未ログインのアクセスを入口で止めるapp/page.js: サーバー側でredirect('/login')するapp/login/page.jsとapp/signup/page.js: ログイン操作の画面だけを担当する- API ルート: データ取得時に
getUser()で確認する
ステップ4: ログアウト処理を追加する
ログアウトはクライアント側で signOut() を呼び出します。ボタン用のコンポーネントを作りましょう。
// components/LogoutButton.js
'use client'
import { useRouter } from 'next/navigation'
import { createClient } from '../lib/supabase/client'
export default function LogoutButton() {
const router = useRouter()
const supabase = createClient()
const handleLogout = async () => {
await supabase.auth.signOut() // ログアウト
router.replace('/login')
router.refresh()
}
return (
<button onClick={handleLogout} style={{ marginTop: '16px' }}>
ログアウト
</button>
)
}次に、ホームページでこのボタンを表示します。
// app/page.js
import { redirect } from 'next/navigation'
import { createClient } from '../lib/supabase/server'
import LogoutButton from '../components/LogoutButton'
export default async function HomePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div style={{ padding: '32px' }}>
<h1>ホーム</h1>
<p>ようこそ、{user.email} さん</p>
<p>ここにログイン後だけ見せたい内容を置きます。</p>
<LogoutButton /> {/* ログアウトボタンを表示 */}
</div>
)
}ステップ5: 動作確認
ここまで実装出来たら、ログイン機能が正しく動くか確認してみましょう。
- ターミナルで
npm run devを実行して開発サーバーを起動する - ブラウザで
http://localhost:3000を開く - ログインしていない状態でホームにアクセスすると、ログインページにリダイレクトされることを確認する
- 「新規登録」リンクからで新規登録し、登録完了のメッセージが出ることを確認する
- 登録後、ホームページに移動して自分のメールアドレスが表示されることを確認する (確認メールを無効にしているので、登録と同時にログイン状態になります)
また、Supabase ダッシュボードの Authentication → Users でユーザーが追加されていることも確認してみましょう。
🔒 認可:「自分のデータしか操作できない」
ログインユーザーのIDを使って、自分のTodoだけ取得・操作できるようにします。
ステップ1: Usersテーブルをスキーマから削除する
Supabase Auth がユーザー情報を管理するので、アプリ側で users テーブルを持つ必要はありません。
prisma/schema.prisma から User モデルを削除して、ユーザー情報は Supabase に任せる方針に揃えます。
ステップ2: userId を String に変更する
次に prisma/schema.prisma の Todo モデルの userId を String に変更します(Supabase Auth のユーザーIDは UUID = 文字列型です)。
model Todo {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
userId String @map("user_id") // ← Int から String に変更
createdAt DateTime @default(now()) @map("created_at")
@@map("todos")
}変更したらマイグレーションを実行します。
npx prisma migrate dev --name add-string-user-id
npx prisma generateマイグレーション時に次の確認が出たら y で続行してください。
⚠️ Warnings for the current datasource:
• You are about to drop the `users` table, which is not empty (10 rows).
? Are you sure you want to create and apply this migration? » (y/N)これは
usersテーブルと中のデータを削除する操作です。サンプルデータなら問題ありませんが、 実データが入っている場合は元に戻せないので、必ずバックアップを取ってから進めてください。
次に、APIルートでログインユーザーのIDを取得して絞り込みます。
// app/api/todos/route.js(認証チェックを追加)
import { NextResponse } from 'next/server'
import { prisma } from '../../../lib/prisma'
import { createClient } from '../../../lib/supabase/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser() // ログインしているユーザーの情報を取得
if (!user) {
return NextResponse.json({ error: 'ログインが必要です' }, { status: 401 })
}
const todos = await prisma.todo.findMany({
where: { userId: user.id }, // ログインユーザーのIDと一致するTodoだけ取得する
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(todos)
}
export async function POST(request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'ログインが必要です' }, { status: 401 })
}
const { title } = await request.json()
if (!title?.trim()) {
return NextResponse.json({ error: 'titleは必須です' }, { status: 400 })
}
const todo = await prisma.todo.create({
data: {
title: title.trim(),
userId: user.id, // ログインユーザーのIDを保存する (取得するときは、このIDがログインユーザーのIDと一致するものに絞り込む)
},
})
return NextResponse.json(todo, { status: 201 })
}ステップ3: 認可の動作確認
- アカウントAでログインし、Todoを1件追加する
- 「ホーム」に戻りログアウトして、別のアカウントBでログインする (必要に応じてアカウントを作成してください)
- 先ほど作ったTodoが表示されないことを確認する
- Supabase の Table Editor(todos テーブル)で
user_idが入っていることを確認する
※user_idはランダムな文字列になります
確認しよう
-
proxy.jsをプロジェクトルートに作成した - サインアップしてログインできた
- ログアウト後にログインページにリダイレクトされた
- ログイン後、自分のTodoだけが表示される
- 別アカウントでは先ほど作ったTodoが表示されない
- todos テーブルの
user_idにUUIDが入っている - 認証と認可の違いを説明できる
AIに聞いてみよう
「Supabase Authを安全に使うために注意すべきことを教えてください」
「Supabase Authはメールアドレス+パスワード認証以外にどんな認証方法に対応していますか?」
お疲れさまでした!
バックエンドセクションはここで完了です!いよいよ自分のアプリを作りましょう。