はじめに
前回の記事では、Supabase を題材に 認証と認可の違い、RLS、API キーの使い分け について整理しました。
今回はその続きとして、Next.js(App Router)+ Supabase で 認証処理をどう書くか に焦点を当てます。
個人開発を進める中で、私が最初につまずいたのは次の2点でした。
- ログイン・ログアウトの API の呼び方 は分かるが、セッションをどこでどう扱うか が曖昧
- 「ログインしているか?」の分岐を クライアントとサーバー、どちらに書くべきか が分からない
本記事では、Supabase 公式ドキュメントおよび公式 Example のコード をベースに、私が読み解いた内容を解説します。
この記事で得られる観点
- ブラウザ用 / サーバー用 クライアントの役割
- Proxy でセッションを更新する理由
- 公式ドキュメントからわかるサインアップ・ログイン・ログアウトの実装方法
-
getClaims()/getUser()/getSession()の 使い分け - 「未ログインならリダイレクト」など よく書く処理 の書き方と置き場所
前提
公式ドキュメント Creating a Supabase client for SSR に従い、次を導入します。
npm install @supabase/supabase-js @supabase/ssr
環境変数(.env.local)は公式 Example と同じ形式です。
NEXT_PUBLIC_SUPABASE_URL=supabase_project_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key
ダッシュボード上では anon key と表示されている場合もありますが、クライアントに公開してよいキーはこの Publishable key です(service_role は使いません)。
なぜクライアントを2種類用意するのか
Supabase 公式ドキュメントでは、Next.js App Router 向けに次の2種類のクライアントが必要だと説明されています。
- Client Component client — ブラウザ上で動くコード向け
- Server Component client — Server Component、Server Actions、Route Handler 向け
引用元: Creating a Supabase client for SSR(Next.js)
| 種類 | ファイル例 | 使う場所 |
|---|---|---|
| ブラウザ用 | lib/supabase/client.ts |
Client Component、ログインフォーム |
| サーバー用 | lib/supabase/server.ts |
Server Component、Route Handler |
Server Componentだけでは Cookieを書き込めない ため、
期限切れの Auth トークンを更新するためのProxyが必要です。
Proxy の役割
Proxyの役割は主に以下のとおりです。
-
supabase.auth.getClaims()で Auth トークンを検証し、必要に応じて更新 - 更新したトークンを Server Component へ渡す(
request.cookies.set) - 更新したトークンをブラウザへ渡す(
response.cookies.set)
ブラウザ用クライアント
以下は Supabase 公式ドキュメント(client.ts) のコードです。
lib/supabase/client.tsに配置します。
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}
-
createBrowserClientは ブラウザ専用 の Supabase クライアントを返します - ログイン・ログアウト、Client Component からの Auth 操作は このクライアント 経由で行います
サーバー用クライアント
以下は Supabase 公式ドキュメント(server.ts) のコードです。
lib/supabase/server.tsに配置します。
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet, _headers) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have proxy refreshing
// user sessions.
}
},
},
}
)
}
-
cookies()は Next.js がリクエストごとに提供するCookie ストアです(公式: cookies) -
getAll/setAllは、Supabase が セッション用 JWT を Cookie に読み書きする ためのフックです -
setAllのtry/catchは公式コメントどおり、Server Component から Cookie を書けない場合がある ためのものです - その場合、セッション更新は後述の Proxy が担います
Proxy でセッションを更新する
以下は Supabase 公式ドキュメントのコードです。
updateSession(lib/supabase/proxy.ts)
Supabase 公式ドキュメント(lib/supabase/proxy.ts) より。
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
)
},
},
}
)
// IMPORTANT: If you remove getClaims() and you use server-side rendering
// with the Supabase client, your users may be randomly logged out.
const { data } = await supabase.auth.getClaims()
const user = data?.claims
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
-
createServerClientとgetClaims()の間に 余計な処理を挟まない -
getClaims()を呼ぶことでAuth トークンを検証し、必要に応じて更新します - 公式ドキュメントでは、未ログイン時に
/loginへリダイレクトする ページ保護 もここに含まれています - 返却する
supabaseResponseは、Cookie を書き換えたレスポンスそのもの です。別オブジェクトに差し替える場合は公式コメントの手順に従う必要があります
プロジェクトルートの proxy.ts
Supabase 公式 Example(proxy.ts) より。
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
export async function proxy(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
- Next.js の Proxy(公式: Proxy)に
updateSessionを接続しています - リクエスト前後でCookieを読み書きし、セッション更新を行います
※公式ドキュメントでは、ここに未ログイン時のページガードも組み合わせています -
matcherにより、静的ファイル以外のリクエストで Proxy が走ります
サインアップ・ログイン・ログアウト
Auth API自体は Password-based Auth および JavaScript リファレンス のコードが基準です。
SSR 構成では、ブラウザ用 createClient() から呼びます。
サインアップ
公式: signUp より
const supabase = createClient()
const { data, error } = await supabase.auth.signUp({
email: 'example@email.com',
password: 'example-password',
})
- ホスト型 Supabase では メール確認が有効 なことが多く、確認後にログイン可能になります
- メール確認後のトークン交換には、公式どおり
app/auth/confirm/route.ts等の Route Handler が必要です(Password-based Auth: PKCE flow)
ログイン
const supabase = createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email: 'example@email.com',
password: 'example-password',
})
- 成功すると JWT が Cookie に保存され、以降のリクエストに自動で付与されます
-
@supabase/ssrが Cookie への保存を担当するため、localStorage へ手動で token を書く必要はありません
ログアウト
公式: signOut より。
const supabase = createClient()
const { error } = await supabase.auth.signOut({ scope: 'local' })
解説
- デフォルトの
scope: 'global'は 全デバイスからログアウト します - 多くのアプリでは 現在のセッションだけ 終了したいため、公式も
{ scope: 'local' }の例を示しています
よく書く処理
公式が示す3つの API
Supabase 公式ドキュメントでは、Auth のユーザー確認に次の3つがあると整理されています(Creating a Supabase client for SSR)。
| API | 通信の有無 | 用途(公式の整理) |
|---|---|---|
getClaims() |
通常はAuthサーバーへのユーザー問い合わせなし(JWTを検証) | ページやデータの 保護。Proxy やページガード向き |
getUser() |
Auth サーバーへ ネットワーク通信あり | 最新のユーザー情報 が必要なとき。確実だが通信コストがある |
getSession() |
セッション情報を取得する。サーバー側でuser情報だけを認可判断に利用しない | access / refresh token 自体が必要なとき。サーバーでの認可判断には user を信頼しない |
ページ保護にはgetClaims()、
最新のユーザー属性が必要ならgetUser()
という使い分けが公式の推奨です。
「通信が発生するかどうか」の視点で選ぶと、
Proxy では getClaims()、
メールアドレス表示など確実性が欲しい場面では getUser()、
と整理できます。
ページを保護するとは?
「マイページ」のように、ログインした人だけが見られる画面を作ることです。
具体的には、ページを表示する前にログイン状態を確認し、未ログインならログイン画面へ送り返します。
ボタンを非表示にするだけでは URL を直接入力されると素通りされるため、表示前のサーバー側で確認するのがポイントです。
サーバーでページを保護する
Proxyのサンプルコードに加え、個別ページでもServer Component内で確認できます。
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function DashboardPage() {
const supabase = await createClient()
const { data } = await supabase.auth.getClaims()
if (!data?.claims) {
redirect('/login')
}
return <div>マイページ</div>
}
- 公式は サーバー側で
getSession()だけを信頼しない よう警告しています(Cookie は改ざん可能なため) -
getClaims()は JWT 署名を検証するため、ページガード向き です
クライアントで UI を出し分ける
ヘッダーの「ログイン / ログアウト」切り替えなど、表示だけ なら Client Component で createClient() を使います。
ここは公式ドキュメントには単独ファイルがありませんが、Auth API の組み合わせです。
'use client'
import { createClient } from '@/lib/supabase/client'
export function LoginButton() {
const supabase = createClient()
async function handleSignOut() {
await supabase.auth.signOut({ scope: 'local' })
}
return (
<button type="button" onClick={handleSignOut}>
ログアウト
</button>
)
}
処理ごとの置き場所の目安
| やりたいこと | 推奨する場所 | 使う API |
|---|---|---|
| 未ログインならページ自体を見せない | Proxy または Server Component | getClaims() |
| ボタン・メニューの表示切替 | Client Component |
signOut 等 |
| 最新の user.email 等をサーバーで表示 | Server Component | getUser() |
| 他人のデータを読めないようにする | RLS(DB) |
auth.uid()(前回整理) |
INSERT 時に user_id を付ける
「ログインユーザーが自分のレシピを登録する」場合、user_id には サーバーで取得したユーザー ID を使います。
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
throw new Error('未ログイン')
}
await supabase.from('recipes').insert({
title,
user_id: user.id,
})
解説
- ここでは Auth サーバー確認付きの
getUser()を使っています - アプリ側で
user_idを付けても、RLS で INSERT を縛っていなければ 改ざんを防げません -
auth.uid() = user_idのポリシーを DB に置くことで、クライアントの注意に頼らない形にできます
よくあるアンチパターン
1. サーバーで getSession() だけ使ってページを保護している
→JWTを介して検証していない
2. Proxy から getClaims() を削除している
→ProxyのキモとなるgetClaims()を消すとトークン更新ができない
3. クライアントの if (user) だけでページを保護している
→クライアントの表示制御だけではページ保護にならない。サーバー側でgetClaims()などを使って認証状態を検証する
4. ログイン済みだから RLS を後回しにしている
→認証と認可は別概念のため、認可となるRLSは開発者が設定する必要がある
5. Server Component 内で Supabase クライアントを使い回している
→リクエストごとにクライアントを生成する ことが推奨されています
最後に
SupabaseのAuth API自体はシンプルですが、
Next.js App RouterではCookie・Proxy・Server / Client の境界を公式どおりに組み立てる必要があります。
私が整理して得たのは、次の順番です。
- 公式ドキュメントの client.ts / server.ts / proxy.ts をそのまま土台にする
- ログイン操作は Client、ページのガードは
getClaims()に寄せる - データの安全性は RLS に委ねる(認証の実装とセット)
前回の記事で「認証と認可の境界」を押さえ、今回の記事で「公式に沿った認証の書き方」を足すと、個人開発でも一通りの土台ができます。
参考リンク
Supabase
- Creating a Supabase client for SSR(Next.js)
- Supabase /auth/nextjs
- Password-based Auth
- Advanced guide(SSR Auth)
- JavaScript: signUp
- JavaScript: signInWithPassword
- JavaScript: signOut
- JavaScript: getClaims
- JavaScript: getUser
- JavaScript: getSession
- Understanding API keys