Claude Code で認証を追加する(Supabase Auth)
Claude Code と Supabase Auth を使って、Next.js 16 アプリにメール/パスワードのサインアップ、Google OAuth、magic link、保護されたルート、セッション管理を追加する方法。
Supabase Auth は、セッション・JWT・OAuth がすでに組み込まれたホスト型のユーザーシステムを提供します。パスワードのハッシュ化ルーチンやトークンの更新ループを自分で書くことはありません。あなたが書くのは、それを Next.js に繋ぐ配管です。2 つの Supabase クライアント、proxy.ts でのセッション更新ステップ、そしてそれを呼ぶフォームと Server Action です。正しいパターンを与えれば、Claude Code はそのすべてを正しく生成できます。この記事では、動くコードとともにそれを順に見ていきます。
Supabase Auth で得られるもの
Supabase Auth は、あなたの Postgres データベースの上で動きます。ユーザーは auth.users というスキーマに存在しますが、あなたがここを直接クエリすることはありません。すべてのサインアップ、ログイン、OAuth フローは同じ形で終わります。Supabase が JWT を発行し、@supabase/ssr がそれを cookie に保存するので、あなたの Next.js サーバーはリクエストごとにそれを読めます。
メール/パスワード認証、magic link(メールで届く 1 回きりのリンクによるパスワードレスなサインイン)、そして 30 以上のプロバイダーに対応した OAuth が最初から使えます。この記事では OAuth の例として Google を扱います。多くのビルダーが最初に手を伸ばすのがこれだからです。Google が動けば、他のどのプロバイダーでもパターンは同じです。
パッケージをインストールする
パッケージは 2 つ、どちらも Supabase 製です。@supabase/supabase-js がコアのクライアント、@supabase/ssr は Next.js のようなサーバーレンダリングのフレームワーク向けに作られた、cookie ベースのセッション管理を追加します。
npm install @supabase/supabase-js @supabase/ssrSupabase プロジェクトのダッシュボード、Project Settings > API の下から、環境変数も 2 つ必要です。
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyanon キーはブラウザに公開しても安全です。アクセス制御は、このキーを隠すことではなく、テーブルの row-level security ポリシーから来ます。
2 つのクライアント:ブラウザとサーバー
Next.js での Supabase Auth には、別々のクライアントが 2 つ必要です。cookie の扱いが両サイドで異なるからです。ブラウザクライアントは document.cookie を通じて cookie を読み書きします。サーバークライアントは、Next.js が渡してくれるリクエストとレスポンスのオブジェクトを通じて読み書きします。
まずブラウザクライアントを Claude に作らせましょう。短いものです。
// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}サーバークライアントは Server Components、Server Actions、Route Handlers の中で使います。Next.js 16 では、next/headers の cookies() が Promise を返すので、await が必要です。
// utils/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_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Called from a Server Component that can't set cookies.
// proxy.ts refreshes the session instead, so this is safe to ignore.
}
},
},
}
)
}この try/catch は飾りではありません。Server Component は cookie を読むことはできても、まったく設定できません。実際にセッションを生かし続けるのは proxy.ts のセッション更新ステップ(次のセクション)なので、ここで Server Component が cookie を設定できないのはバグではなく想定どおりの動作です。
メール/パスワードのサインアップとログイン
フォームの送信は Server Action が処理します。サインアップもログインも同じ形です。FormData からフィールドを取り出し、Supabase のメソッドを呼び、成功したらリダイレクトします。
// app/login/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function signup(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signUp({ email, password })
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/', 'layout')
redirect('/check-email')
}
export async function login(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/', 'layout')
redirect('/dashboard')
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
revalidatePath('/', 'layout')
redirect('/login')
}デフォルトでは、Supabase は新しいアカウントがログインできるようになる前にメール確認を要求します。signup がダッシュボードへ直行せず /check-email ページへリダイレクトするのはそのためです。ローカルテストのために Supabase ダッシュボードの Authentication > Providers で確認をオフにできますが、本番ではオンのままにしておきましょう。
フォーム自体はバリデーションの状態を表示できるよう Client Component ですが、送信アクションはサーバー上で走ります。
// app/login/page.tsx
import { login, signup } from './actions'
export default function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>
}) {
return (
<form className="max-w-sm mx-auto py-12 space-y-4">
<h1 className="text-2xl font-bold">Log in</h1>
<input
id="email"
name="email"
type="email"
required
placeholder="you@example.com"
className="w-full border rounded px-3 py-2"
/>
<input
id="password"
name="password"
type="password"
required
placeholder="Password"
className="w-full border rounded px-3 py-2"
/>
<div className="flex gap-2">
<button formAction={login} className="flex-1 bg-black text-white rounded py-2">
Log in
</button>
<button formAction={signup} className="flex-1 border rounded py-2">
Sign up
</button>
</div>
</form>
)
}searchParams.error の表示を Claude に追加させれば、エラーメッセージがタダで手に入ります。Next.js 16 では searchParams は Promise なので、Server Component の中でその値を直接使うなら、読むときにやはり await が必要です。
proxy.ts でのセッション更新
Next.js 16 は middleware.ts を proxy.ts にリネームし、エクスポートする関数も middleware から proxy に変わりました。プロジェクトの指示がないと Claude はいまだにデフォルトで middleware.ts を書くので、CLAUDE.md に 1 行足しておきましょう。「Middleware logic goes in proxy.ts, exported as proxy, not middleware.ts.」
更新ロジックは、テストと再利用がしやすいよう、まずヘルパーファイルに置きます。
// utils/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_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
const protectedPaths = ['/dashboard', '/account']
const isProtected = protectedPaths.some((path) => request.nextUrl.pathname.startsWith(path))
if (!user && isProtected) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}ここで getUser() を呼ぶと、2 つのことが同時に起きます。トークンを Supabase のサーバーに対して検証し、そしてトークンが期限切れに近ければセッション cookie を更新します。これが保護されたページだけでなくリクエストごとに走るのはそのためです。期限切れのセッションは、切れたあとではなく切れる前に更新する必要があるのです。
ルートの proxy.ts ファイルは、ただヘルパーを呼び、どのパスで走るかを定義するだけです。
// proxy.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/proxy'
export async function proxy(request: NextRequest) {
return updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}matcher は静的アセットと画像を除外するので、proxy が favicon のリクエストごとにセッションチェックを走らせることはありません。それでもすべてのページと API ルートでは走るので、アプリ全体でセッションが新鮮に保たれます。
ルートを保護する(多層防御)
上の proxy のリダイレクトはたいていのケースをカバーしますが、これを唯一のチェックだと考えてはいけません。matcher パターンは微妙に間違えやすく、保護したかったルートを黙って除外してしまうと、そのルートは今や無防備です。ページの中で改めて確認しましょう。
// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return <>{children}</>
}これは保護されたページの読み込みごとに Supabase への往復が 1 回余分にかかります。正規表現の matcher を唯一の認可境界に頼らずに済むなら、妥当なトレードオフです。この 2 つのチェックの下にある本当の最後の砦は、テーブルの row-level security です(後述)。攻撃者が回避できない層だからです。
Google OAuth
コードを書く前に、Supabase ダッシュボードの Authentication > Providers で Google プロバイダーをオンにします。Google Cloud Console から Client ID と Client Secret が必要で、Google に受け入れさせるリダイレクト URI は、そのページで Supabase が表示する https://your-project-ref.supabase.co/auth/v1/callback の形のものです。
サインインアクションは Supabase から URL を要求し、ブラウザをそこへリダイレクトします。
// app/login/actions.ts (add to the same file)
export async function signInWithGoogle() {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error || !data.url) {
redirect('/login?error=Could not sign in with Google')
}
redirect(data.url)
}NEXT_PUBLIC_SITE_URL には、実際にデプロイした URL(開発時は http://localhost:3000)を設定しておく必要があります。そうすればリダイレクトが Supabase のデフォルトではなく、あなたのアプリに戻ってきます。
<form>
<button formAction={signInWithGoogle} className="w-full border rounded py-2">
Continue with Google
</button>
</form>Magic link
magic link は、メールで送られる 1 回きりのサインインリンクです。設定したり覚えたりするパスワードはありません。Supabase は内部でこれをパスワードレスの OTP フローと呼び、OAuth と同じコールバックルートを使います。
// app/login/actions.ts (add to the same file)
export async function sendMagicLink(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
redirect('/check-email')
}必要なのはメールフィールドだけでパスワードは要らないので、これは専用の小さなフォームに繋ぎましょう。
<form className="space-y-2">
<input name="email" type="email" required placeholder="you@example.com" className="w-full border rounded px-3 py-2" />
<button formAction={sendMagicLink} className="w-full border rounded py-2">
Send magic link
</button>
</form>両方のフローが共有するコールバックルート
Google OAuth と magic link は、どちらも Supabase がブラウザを code クエリパラメータ付きであなたのアプリに戻すことで終わります。1 つの Route Handler が、その code をセッションと交換します。
// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=Could not authenticate`)
}exchangeCodeForSession が呼ばれるのはここだけです。すべての OAuth プロバイダーとすべての magic link がここを指すので、後で 2 つ目のプロバイダーを追加しても、新しいコールバックルートは要りません。同じ /auth/callback へリダイレクトする新しいサインインアクションを追加するだけです。
auth.uid() に紐づけた row-level security
上の認証フローだけでは、ログイン済みユーザーがデータベースで何を見られるかは何も制限されません。それは Postgres の仕事で、row-level security(RLS)を通じて強制されます。よくあるパターンは、誰かがサインアップするたびに自動で 1 行が作られる profiles テーブルです。auth.users に対する Postgres のトリガーを使います。
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text,
display_name text,
created_at timestamptz default now()
);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();テーブルができたら RLS をオンにし、行の所有者をログイン中のユーザーの ID と照合するポリシーを書きます。
alter table public.profiles enable row level security;
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = id );
create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );auth.uid() は、@supabase/ssr がすべてのリクエストに付ける検証済みの JWT からユーザー ID を読み取ります。このテーブルについては、アプリケーションコードに別途の認可チェックを書く必要はありません。行が呼び出し元のものでなければ、Postgres がそのクエリをきっぱり拒否します。これは proxy.ts のリダイレクトや上のレイアウトチェックを支えるのと同じ保証ですが、ルートチェックの抜け漏れでスキップされることがない点が違います。RLS の全体像(INSERT/DELETE ポリシー、ビュー、そして security_invoker の落とし穴)については、Claude Code With Supabase を参照してください。
繋ぎ終えたら、このフロー全体を一度端から端まで実行するよう Claude に頼みましょう。新しいメールでサインアップし、確認し、ログインし、Google でサインインし、magic link を要求し、ログアウト状態で保護されたページに当たってリダイレクトを確認する。これこそが本当に大事なテストで、クリーンな型チェックだけでは足りません。
ここに出てくるすべて(2 つのクライアント、proxy.ts での更新、コールバックルート、RLS ポリシーの形)は、Claude Code が一度は正しく書けるものですが、どこかにパターンを保存しておかない限り、新しいプロジェクトごとにゼロから導出し直されます。$29 の Code Kit はこれをすでに繋いだ状態で出荷します。保護されたページ、Google OAuth、最初のマイグレーションから全テーブルに RLS。だから新しいプロジェクトは、この配管を作り直すのではなく、その先から始まります。これは Claude Code の上に載る一度きりの harness で、サブスクリプションではありません。そして Claude Code 自体は、その下でやはり有料の Anthropic プランを必要とします。
Posted by @speedy_devv

