ローカルPlaywrightテストが本番SupabaseDBを汚した — SERVICE_ROLE_KEY分離と data prefix 戦略
結論:.env.local に本番 SERVICE_ROLE_KEY があるとローカルテストが本番を汚す
3行サマリー:
.env.localに本番SUPABASE_SERVICE_ROLE_KEYを設定したまま Playwright E2E テストを動かすと、ローカルサーバー経由でも本番テーブルに insert が通るSERVICE_ROLE_KEYは RLS を完全に bypass するため「テスト環境だから安全」が成立しない- 対策は①
.env.local.testで本番キーを隔離、② test data prefix 規約、③npx supabase startでローカル Supabase を使う、の3択
今すぐ確認するチェックリスト:
-
.env.localにSUPABASE_SERVICE_ROLE_KEYが本番値で入っていないか - Playwright テスト実行時のアプリが本番 Supabase URL を参照していないか
- テストで insert したデータを cleanup するコードが書かれているか
何が起きたか
demo-gallery を友人の LINE グループで拡散する前夜、P1 監査として Playwright E2E テストを走らせました。
テストしたのは 2 ルートです。
- 投票送信(
POST /api/vote) - 拒否理由送信(
POST /api/reject)
テストは全パス。グリーンのまま終了しました。ところが翌日、Supabase ダッシュボードで demo_votes テーブルを確認すると、見覚えのない行が 2 件ありました。
SELECT id, comment, created_at FROM demo_votes ORDER BY created_at DESC LIMIT 5;
-- id | comment | created_at
-- 1 | [Playwright] vote test | 2026-05-15 04:58:12
-- 2 | [Playwright] reject test | 2026-05-15 04:59:07
Playwright テストが、本番テーブルに直接 insert していたのです。
拡散は予定より 1 日ずらし、DELETE FROM demo_votes WHERE comment LIKE '[Playwright]%' で削除してから拡散しました。もし削除を忘れて拡散していたら、集計結果にテストデータが混入したまま公開することになっていました。
なぜ起きるのか
/app/api/vote/route.ts の実装を確認すると、こういう構造になっていました。
// /app/api/vote/route.ts
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // ← RLS を bypass するキー
)
export async function POST(req: Request) {
const { comment } = await req.json()
const { error } = await supabase.from('demo_votes').insert({ comment })
if (error) console.error(error)
return Response.json({ ok: true })
}
そして .env.local には本番 Supabase の接続情報が入っていました。
# .env.local(本番値)
NEXT_PUBLIC_SUPABASE_URL=https://amtwwscvhwkfdrqimgqm.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...(本番キー)
Playwright の動作を整理すると:
- Playwright が
next startで起動したローカルサーバー(http://localhost:3000)にリクエストを送る - ローカルサーバーは
.env.localを読み込んでいる .env.localには本番 Supabase の URL とSERVICE_ROLE_KEYが入っているSERVICE_ROLE_KEYは Supabase の RLS を bypass する- 結果として本番テーブルへの insert が成功する
「ローカルで動かしているテスト」が「本番 DB に書いている」という構造です。
Resend などのメール送信サービスは mock していたのに、Supabase への書き込みは mock していなかった、という非対称が原因でした。Supabase クライアントはネイティブの fetch を使っており、一般的な HTTP mock では捕捉されません。
よくある思い込み
「ローカルサーバーで動かしてるから、本番には影響しないはず」
この思い込みが罠です。
Playwright はアプリのプロセス内部に干渉するのではなく、HTTP クライアントとして動作します。テスト対象のサーバーが本番 Supabase に接続していれば、Playwright 経由のリクエストも本番に届きます。
外部サービス(Resend / Stripe / Slack)を mock しているケースでも、Supabase への書き込みが mock から漏れていることがあります。特に Route Handler の中でインスタンス化される createClient は、モジュールレベルの mock が効きにくい設計になっているため、.env.local の値がそのまま使われます。
もう一つの罠は「anon key ではなく service_role key を使っているから RLS が守ってくれる」という逆の思い込みです。SERVICE_ROLE_KEY を使うと RLS が完全に無視されます。テスト環境で発行したデータが production テーブルに全件書き込まれます。
対策3つ
対策1: .env.local.test で本番キーを隔離する
Playwright 実行専用の環境変数ファイルを作り、本番キーを持ち込まない構成にします。
# .env.local.test(ローカル Supabase or staging プロジェクト)
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...(ローカルキー)
playwright.config.ts で webServer の起動コマンドを上書きします。
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
use: {
baseURL: 'http://localhost:3001',
},
webServer: {
command: 'DOTENV_CONFIG_PATH=.env.local.test npx next start -p 3001',
port: 3001,
reuseExistingServer: !process.env.CI,
},
})
.env.local.test は .gitignore に追加して、本番キーをコミットしないようにします。
# .gitignore に追加
.env.local.test
対策2: test data prefix 規約で一括削除可能にする
本番や staging への書き込みを完全に防げない状況では、テストデータを識別可能にします。Playwright の globalSetup でプレフィックスを規約化しておくと、1 SQL で全件削除できます。
// tests/global-setup.ts
export default async function globalSetup() {
process.env.TEST_DATA_PREFIX = '[TEST]'
}
// テストファイル内でプレフィックスを使う
await page.request.post('/api/vote', {
data: { comment: `${process.env.TEST_DATA_PREFIX} 投票テスト` }
})
-- テスト後の一括削除(globalTeardown から実行)
DELETE FROM demo_votes WHERE comment LIKE '[TEST]%';
コメント先頭を [TEST] に固定するだけで、テストデータと本番データを 1 SQL で分離できます。今回の Playwright インシデントで、コメントに [Playwright] という文字列が偶然入っていたため削除できましたが、これを意図した規約にしておくべきでした。
対策3: npx supabase start でローカル Supabase を使う
最も安全な方法は、本番 Supabase とは完全に分離したローカル環境を使うことです。
# Supabase CLI をインストール(未インストールの場合)
npm install -g supabase
# ローカル Supabase を起動
npx supabase start
# 起動後に接続情報が表示される
# API URL: http://127.0.0.1:54321
# anon key: eyJhbGc...(ローカル専用)
# service_role key: eyJhbGc...(本番とは異なるキー)
起動後、.env.local.test に表示された URL とキーを設定すれば、ローカル Supabase に向けた完全な分離環境が完成します。マイグレーションも npx supabase db push でローカルに適用できます。
個人開発では本番プロジェクト 1 つで開発・テスト・本番を兼ねているケースが多いですが、E2E テストを走らせる場面でだけでも対策3を使うと安全性が大幅に上がります。
どれを選ぶか
| 状況 | 推奨対策 |
|---|---|
| 開発初期で手間をかけたくない | 対策2(prefix 規約のみ) |
| Stripe や Resend の mock も整備中 | 対策1(.env.local.test 分離) |
| マイグレーション管理が整備済み | 対策3(ローカル Supabase) |
| CI/CD で Playwright を動かしたい | 対策3 + 対策1の組み合わせ |
個人開発フェーズでは対策1と対策2の組み合わせが現実解です。ローカル Supabase のセットアップに時間をかけるより、まず本番キーを .env.local.test に隔離することを優先してください。
今日やること(3つ)
.env.localにSERVICE_ROLE_KEYが本番値で入っていないか確認する → 入っていれば.env.local.testを作成して Playwright 用に本番キーを移す- Playwright テストのデータに
[TEST]prefix を付ける →globalSetupまたは fixture で一括付与できる - テスト後の cleanup SQL を
globalTeardownに追加する →DELETE FROM テーブル名 WHERE 識別カラム LIKE '[TEST]%'の形で書いておく
で、どう稼ぐ?
テスト環境と本番環境の分離は、収益化インフラを守る安全弁です。
demo-gallery のケースでは「投票集計にテストデータが混じる」程度で済みましたが、Stripe や Resend の本番キーが .env.local に入っている場合は話が変わります。
Playwright テストで Stripe の checkout フローをテストした場合、STRIPE_SECRET_KEY が本番値なら実際に課金が発生します。RESEND_API_KEY が本番値なら実際にメールが送信されます。個人開発では「最初はとにかく動かす」という方針が合理的ですが、E2E テストを書き始めるタイミングで環境変数の整理をセットでやっておくと、将来のトラブルを防げます。
収益を守るために分離すべき環境変数の優先度:
SUPABASE_SERVICE_ROLE_KEY(本番 DB への RLS bypass 書き込み)STRIPE_SECRET_KEY(実課金)RESEND_API_KEY(実メール送信・送信数カウント)
この3つが .env.local に本番値で入っている場合、E2E テストを追加する前に .env.local.test を作ってください。
環境変数の分離ができると、テストを気軽に走らせられるようになります。テストを気軽に走らせられると、リグレッションを早期発見できます。早期発見できると、有料ユーザーに迷惑をかけるバグを減らせます。ユーザー体験が安定すると、解約率が下がります。
この連鎖が収益の安定につながります。
demo-gallery の公開前夜、P1 監査の Playwright E2E テストが本番の demo_votes テーブルに 2 行 insert した実インシデント。拡散前に発見し、DELETE FROM demo_votes WHERE comment LIKE '[Playwright]%' で削除済み。現在は comment プレフィックス規約を導入し、test データを 1 SQL で一括削除できる構造に変更した。masatoman.net は現在 Free MVP 運用中、記事公開とベースライン計測を継続中。節目で数字を公開します。
masatoman のメルマガ — 毎週月曜の朝に手紙を 1 通
masatoman.net の今週の記事 1 本を、読者目線で深掘りした手紙が毎週月曜 9:00 に届きます。「これ自分のことだ」が見つかる予告編。登録特典に「個人開発の収益化チェックリスト 15 項目」。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
Next Step
次に読むならこの導線です
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
【第10回】Claude Code × Supabase で管理画面を30分で生成する Skill 実装ガイド
Supabase のテーブルを参照して管理画面を自動生成する Skill を、実コード付きで解説。Claude Code が DB スキーマから Next.js 管理画面を30分で生成する仕組みを公開します。
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
Claude Code で 0→MVP を1日で作る全記録 — recipe-ai Build in Public
Claude Codeを使い、YouTube料理動画からレシピを自動抽出するAIアプリ「recipe-ai」を0からMVPまで1日で構築した全記録。CLAUDE.md設計、API実装、Supabase連携、Vercelデプロイ、Stripe課金導入までの工程を時系列で公開。
次の実験記録も追う
Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。
masatoman のメルマガ — 毎週月曜の朝に 1 通
masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。
この記事が役に立ったらシェア