はじめに:認証方式は「ユーザー体験」より先に「自動化できるか」で決まった
当社(合同会社ジョインクラス)は、社員ゼロ・AIエージェントで事業を回している小さな会社です。開発スタックは Next.js / TypeScript / Supabase で、自社SaaS「PasPoy」もこの構成で動いています。
PasPoyでは紹介動画用の「実際の画面」を、Playwrightで自動録画しています。このスクリプトを書いたときに痛感したのが、認証方式によって自動化の難易度がまるで違うということでした。Email+パスワードなら4行でログインできるのに、OAuthやMagic Linkだと外部のIdPやメールボックスが絡んできて、そのままでは自動化できません。
この記事では、Supabase Authの3方式(Email+パスワード / OAuth / Magic Link)について、
- 実装コード(Next.js App Router +
@supabase/ssr) - それぞれの落とし穴
- Playwrightでの検証方法(当社で実際に動いているスクリプトがベース)
を並べて比較します。読み終えたらその日のうちに、ローカルSupabaseで3方式すべてを試せる粒度で書いています。
3方式の比較表
| 観点 | Email+パスワード | OAuth(GitHub/Google) | Magic Link |
|---|---|---|---|
| 実装量 | 少 | 中(コールバック必須) | 中(コールバック必須) |
| ユーザー体験 | パスワード管理が負担 | ワンクリック | メールを開く手間 |
| パスワード漏えいリスク | あり | なし | なし |
| E2Eテストの自動化 | 容易 | 難(IdPのbot判定) | ローカルなら容易(Inbucket/Mailpit) |
| 外部依存 | なし | IdPのアプリ登録 | SMTP(本番はレート制限に注意) |
| メール到達性の影響 | 確認メールのみ | なし | ログインのたびに受ける |
結論を先に書くと、当社は「本番はOAuth + Magic Link、テスト/デモ用アカウントはEmail+パスワード」という併用にしています。理由は後半の検証パートで説明します。
前提:ローカルSupabaseを立ち上げる
npm i @supabase/supabase-js @supabase/ssr
npx supabase init
npx supabase start
# 出力される API URL / anon key を .env.local に入れる
# Inbucket(またはMailpit) URL: http://127.0.0.1:54324 ← Magic Linkの検証で使う
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=<supabase start の出力>
NEXT_PUBLIC_SITE_URL=http://localhost:3000
OAuthとMagic Linkはリダイレクト先を許可リストに入れる必要があります。ローカルでは supabase/config.toml で設定します。
[auth]
site_url = "http://localhost:3000"
additional_redirect_urls = ["http://localhost:3000/auth/callback"]
[auth.email]
enable_confirmations = false # ローカルでは確認メールを省略
[auth.external.github]
enabled = true
client_id = "env(SUPABASE_AUTH_GITHUB_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_GITHUB_SECRET)"
redirect_uri = "http://127.0.0.1:54321/auth/v1/callback"
落とし穴1: GitHub側のOAuth Appに登録するCallback URLは、アプリの /auth/callback ではなく SupabaseのURL(.../auth/v1/callback) です。ここを間違えると redirect_uri mismatch で止まります。
共通部品:サーバー側クライアント
3方式すべてで使います。Cookieにセッションを保存するため @supabase/ssr を使います。
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: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
},
},
},
);
}
パターン1:Email + パスワード
最もシンプルです。Server Actionで完結し、コールバックルートも不要です。
'use server';
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
export async function signInWithPassword(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email: String(formData.get('email')),
password: String(formData.get('password')),
});
if (error) redirect('/login?error=invalid_credentials');
redirect('/dashboard');
}
import { signInWithPassword } from './actions';
export default function LoginPage() {
return (
<form action={signInWithPassword}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">ログイン</button>
</form>
);
}
落とし穴2: エラーメッセージを error.message のまま返すと、「メールアドレスが存在しない」のか「パスワードが違う」のかを区別できる実装になりがちです(ユーザー列挙)。上のように固定のエラーコードに丸めます。
パターン2:OAuth(GitHub / Google)
OAuthは「IdPへリダイレクト → Supabaseへ戻る → アプリの /auth/callback でcodeをセッションに交換」の3段階です。PKCEフローなので コールバックルートが必須 です。
export async function signInWithGitHub() {
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` },
});
if (error || !data.url) redirect('/login?error=oauth');
redirect(data.url);
}
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/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) return NextResponse.redirect(`${origin}/login?error=no_code`);
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) return NextResponse.redirect(`${origin}/login?error=exchange`);
// オープンリダイレクト対策:相対パスのみ許可
const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/dashboard';
return NextResponse.redirect(`${origin}${safeNext}`);
}
落とし穴3: next パラメータをそのままリダイレクトに使うと、?next=//evil.example でオープンリダイレクトになります。上の safeNext のように必ず検証します。
落とし穴4: PKCEのcode verifierはCookieに入ります。localhost と 127.0.0.1 を混在させるとCookieが別ドメイン扱いになり、exchangeCodeForSession が code verifier not found で失敗します。site_url とブラウザで開くURLのホストを揃えてください。
パターン3:Magic Link(OTP)
パスワード不要でメールのリンクからログインします。実はコールバックはOAuthと同じ /auth/callback を共用できます(PKCEで code が返ってくるため)。
export async function signInWithMagicLink(formData: FormData) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithOtp({
email: String(formData.get('email')),
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
shouldCreateUser: false, // 招待制なら false。未登録メールで勝手にユーザーが作られない
},
});
// 存在有無を漏らさないため、成功・失敗に関わらず同じ画面へ
if (error) console.error('[magic-link]', error.message);
redirect('/login/check-email');
}
落とし穴5: リンクを別のブラウザ(例:PCで申請してスマホのメールアプリで開く)で開くと、code verifierのCookieが無いため失敗します。ユーザーからの問い合わせが一番多いのがこれです。対策として、メールテンプレートを6桁OTPコード入力方式({{ .Token }} を表示し verifyOtp({ email, token, type: 'email' }) で検証)に切り替える手があります。
落とし穴6: Supabase標準のSMTPは送信数の制限が厳しく、本番でMagic Linkを主軸にするなら独自SMTP(Resend / SendGrid等)の設定が事実上必須です。ログインのたびにメールが飛ぶので、到達率がそのままログイン成功率になります。
検証:Playwrightで3方式をテストする
ここが本題です。冒頭で触れたPasPoyの画面録画スクリプトでは、Email+パスワードのデモアカウントで次のようにログインしています(実運用中のコードから抜粋)。
import { chromium, devices } from '@playwright/test';
const BASE = process.env.PASPOY_BASE_URL ?? 'http://localhost:3100';
const EMAIL = 'demo1@test.local';
const PASSWORD = 'demo-password-123';
async function login(page) {
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
await page.fill('input[name="email"]', EMAIL);
await page.fill('input[name="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15000 });
}
ポイントは2つです。
- デモユーザーは
npm run seed:localでローカルSupabaseに投入しておく(本番の認証情報をスクリプトに書かない) - ログイン完了判定は「特定URLへの遷移」ではなく 「/loginから離れたこと」 で待つ。リダイレクト先を変えてもテストが壊れない
シード用のユーザー作成は、service_role キーで Admin API を使うと確認メールをスキップできます。
import { createClient } from '@supabase/supabase-js';
const admin = createClient(
'http://127.0.0.1:54321',
process.env.SUPABASE_SERVICE_ROLE_KEY!, // ローカル専用。本番キーは絶対に使わない
);
const { error } = await admin.auth.admin.createUser({
email: 'demo1@test.local',
password: 'demo-password-123',
email_confirm: true,
});
if (error && !error.message.includes('already')) throw error;
Magic Linkはローカルのメール受信箱から拾う
supabase start で立ち上がるInbucket(新しいCLIではMailpit)にはHTTP APIがあるので、メール本文からリンクを抜き出せます。以下はMailpitの例です。
import { test, expect } from '@playwright/test';
const MAIL_API = 'http://127.0.0.1:54324/api/v1';
test('Magic Linkでログインできる', async ({ page, request }) => {
await request.delete(`${MAIL_API}/messages`); // 受信箱を空にする
await page.goto('/login');
await page.fill('input[name="email"]', 'demo1@test.local');
await page.click('button:has-text("メールでログイン")');
await expect(page).toHaveURL(/check-email/);
const list = await (await request.get(`${MAIL_API}/messages`)).json();
const msg = await (await request.get(`${MAIL_API}/message/${list.messages[0].ID}`)).json();
const link = msg.Text.match(/https?:\/\/\S+verify\S+/)?.[0];
expect(link).toBeTruthy();
await page.goto(link!); // 同じブラウザコンテキストで開く=落とし穴5を回避
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
});
OAuthはE2Eで「通さない」
GitHub/Googleのログイン画面を自動操作するのは、IdP側のbot判定に引っかかりやすく、テストが不安定になります。当社では次のように割り切っています。
- E2E:OAuthボタンを押して IdPのURLへ遷移すること までを確認
- コールバック以降:
/auth/callbackのルートハンドラを単体テスト(codeなし・不正nextの分岐) - 実際のOAuthログイン:リリース前に人間が1回だけ確認
test('GitHubログインはIdPへ遷移する', async ({ page }) => {
await page.goto('/login');
await page.click('button:has-text("GitHubでログイン")');
await page.waitForURL(/github\.com\/login/);
});
どれを選ぶべきか
当社の判断基準をまとめます。
- BtoB・社内ツール:Google/Microsoft OAuth一択。パスワードを持たないのが最大のセキュリティ対策
- BtoC・低頻度利用:Magic Link(OTPコード方式)。パスワードを忘れる層に強い。ただし独自SMTPは必須
-
どの方式を選んでも:テスト/デモ用にEmail+パスワードを残す。ただし本番では
signUpを閉じ、Admin APIで作ったアカウントだけにする
3方式は排他ではなく、同じ /auth/callback と同じ auth.users に乗るので、併用コストは想像より小さいです。
まとめ
- Email+パスワードは実装最小・自動化最強。ただしユーザー列挙とパスワード管理に注意
- OAuthとMagic Linkはコールバックルートを共用でき、PKCEのCookie(ホスト不一致・別ブラウザ)が最大の落とし穴
-
nextパラメータのオープンリダイレクト対策を忘れない - E2Eは「Email+パスワードでシード」「Magic Linkはローカル受信箱から拾う」「OAuthはIdP遷移まで」で安定する
僕自身、「認証を自動テストできるか」を最初に考えるようになってから、AIエージェントにE2Eを任せられる範囲が一気に広がりました。人間が毎回ログインして確認する工程が残っていると、自動化はそこで止まります。
参考書籍
認証まわりの設計は、そのまま「AIエージェントに何の権限を渡すか」という問題にもつながります。service_roleキーの扱い、シークレットの置き場所、エージェントに触らせてよい範囲などを、当社の運用ルールとしてまとめたのがこちらの本です。
- 『企業のためのClaude Codeセキュリティガイド』
- Playwright等での自動検証をcron/Hooksで回す話は『Claude Code 全自動化バイブル』で扱っています