第1章 マネージドアプリ・データプラットフォーム(基礎:Vercel・Supabase)
学習目標
- Vercelのデプロイ環境(Preview・Production)の分離原則と、環境変数の露出境界(
NEXT_PUBLIC_対サーバー専用変数)を区別して設定する。 - デプロイ保護(Deployment Protection)によってプレビュー環境を外部からロックし、自動化テストやWebhookが通過できるようバイパスシークレット値(
x-vercel-protection-bypass)を安全に設計する。 - サーバーレス関数のリージョン(例:ソウル
icn1)をデータベースの所在地に合わせて配置し、実行時間上限(maxDuration)およびFluid Compute・ストリーミングがAI応答の遅延とコストに与える影響を説明する。 - Supabase PostgresのコネクションプーラーであるSupavisorのトランザクションモード(ポート6543)とセッションモード(ポート5432)の違いを理解し、ワークロードに合った接続構成を選ぶ。
- Row Level Security(RLS)の
USING句とWITH CHECK句の動作の違いを説明し、anonキーとservice_roleキーの信頼境界を守るポリシーを作成する。 - Supabase AuthのリダイレクトURL許可リスト(Webオリジンとモバイルディープリンク)および非公開Storageバケットの署名付きURL(Signed URL)によるアクセス制御を設計する。
はじめに
週末のハッカソンで、コーディングエージェントと一緒に素晴らしいAI文書要約Webアプリを作ったスタートアップチームがいます。ローカルPCで順調に動いていたコードをGitHubにプッシュしてVercelにつなぐと、わずか1分で世界中からアクセス可能なHTTPSアドレスが発行され、Supabaseプロジェクトをつなぐとログインとデータベース、ファイルアップロードまでが一気に完成しました。チームは歓声を上げ、月曜日の朝にSNSへリリースの知らせを投稿しました。
ところが、リリースから2時間のうちに3つのトラブルが立て続けに発生しました。第1に、あるユーザーがブラウザの開発者ツールのNetworkタブとJavaScriptバンドルを開いてみたところ、チームの有料LLM APIキーとSupabase管理者キーをそのまま見つけ出してしまいました。開発者がフレームワーク内で変数を手軽に読み込もうとして、すべての環境変数の先頭にNEXT_PUBLIC_を付けていたせいでした。第2に、ユーザーが200人ほど同時に集まると、要約画面が3秒以上固まったあげく、remaining connection slots are reserved for non-replication superuser connectionsというデータベース接続枯渇エラーを吐き出して停止してしまいました。サーバーレス関数は米国東部(iad1)で起動し、データベースはソウル(icn1)にあったため往復遅延が長くなったうえに、関数インスタンスごとに直接接続(ポート5432)をつかんで離さなかったためです。第3に、まだリリースしていない来月の有料機能ブランチのプレビュー(Preview)URLが検索エンジンと外部コミュニティに露出し、誰でも実験中の機能を開いて見られる状態になっていました。
VercelやSupabaseのようなマネージドプラットフォームは、インフラ構築にかかる時間を数日から数分へと短縮してくれますが、**「デフォルト値のまま放置しても安全である」という意味では決してありません。**むしろデプロイが簡単である分、環境変数1つ、ポート番号1つ、RLSのチェックボックス1つが、そのまま本番環境のセキュリティ境界に直結します。この章では、マネージドアプリ・データプラットフォームを「おもちゃのプロトタイプ」の段階から「実戦の本番環境(プロダクション)」の段階へと引き上げるための9つの重要な原理と設計基準を順番に見ていきます。
重要な概念
Vercelの環境分離:PreviewとProductionの境界
マネージドアプリプラットフォームで最初に打ち立てるべき柱は、**環境分離(Environment Separation)**です。VercelはGitリポジトリと連携すると、大きく3つの環境を区別します。開発者のノートPC上で動くDevelopment、機能ブランチやプルリクエスト(PR)ごとに自動生成されるPreview、そしてメインブランチ(例:main)にマージされるか明示的に昇格(Promote)されたときに実際の顧客向けドメインに結びつくProductionです。
たとえるなら、Productionはお客さんを迎えるレストランの本店舗であり、Previewは新メニューを試食して調理動線をテストする試作キッチンです。もし試作キッチンと本店舗が同じ金庫、同じ食材倉庫、同じ注文伝票を使っていたらどうなるでしょうか。試作キッチンで新しい決済キャンセルのテストをしていたら本店舗のお客さんの実際の決済を取り消してしまったり、テスト用の架空注文が何千件も本店舗の帳簿に紛れ込んだりしてしまいます。
したがって、環境分離には3つの鉄則が伴います。
| 区分項目 | Preview環境 | Production環境 | 分離しなかった場合の事故 |
|---|---|---|---|
| ドメイン | ブランチ・コミットごとの固有プレビューURL | 実際のサービスドメイン(例:app.example.com) |
未検証のコードが実際の顧客に露出する |
| データベース・ストレージ | 開発・ステージング専用DBまたは隔離されたテストプロジェクト | 実際の顧客データが入った本番DB | マイグレーション実験やE2Eテストが本番データを毀損する |
| 外部決済・メッセージングキー | サンドボックス(テスト)キー、送信遮断モード | ライブ決済キー、実送信キー | テスト中に実際の顧客へ通知が飛んだり実決済が発生したりする |
| 認証・開発者バックドア | 制限されたテストアカウントを許可(デプロイ保護の背後でのみ) | テストログイン・権限付与APIを完全無効化 | 攻撃者が開発用の権限付与経路から管理者権限を奪取する |
とりわけVercelで環境変数を登録する際は、変数ごとに適用対象の環境(Production、Preview、Development)を個別のチェックボックスで指定できます。本番データベースの接続文字列(DATABASE_URL)やライブ決済の秘密鍵を登録するときに、うっかり3つの環境すべてにチェックを入れてしまうと、どの開発者でもPRを1つ開いてPreviewビルドを立ち上げ、コンソールログに本番シークレット値を出力したり本番DBに接続したりできるようになってしまいます。本番のシークレット値は必ずProduction環境にのみ注入しなければならず、Preview環境には別途用意したステージング用の認証情報を注入しなければなりません。また、Previewブランチごとに隔離された設定が必要な場合は、ブランチ別環境変数(Branch-specific Environment Variables)を指定して実験の範囲を絞ることができます。
環境変数とNEXT_PUBLIC_の露出境界
Webアプリケーションフレームワーク(Next.jsなど)は、サーバー上で実行されるコードと、ユーザーのブラウザへ転送されて実行されるクライアントコードを1つのリポジトリの中で一緒に扱います。ここで初心者から熟練者まで誰もが最も頻繁に経験するセキュリティ事故が、まさに環境変数のバンドル露出です。
ビルドツールはセキュリティのため、デフォルトですべての環境変数を**サーバー専用(Server-only)**として扱います。つまりprocess.env.OPENAI_API_KEYやprocess.env.SUPABASE_SERVICE_ROLE_KEYは、サーバーコンポーネント、APIルート、サーバーアクションのようにVercelのサーバー環境でのみ読み取ることができ、ブラウザへ配信されるJavaScriptファイルには含まれません。
一方で、変数名がNEXT_PUBLIC_で始まる場合(例:NEXT_PUBLIC_SITE_URL、NEXT_PUBLIC_SUPABASE_URL)は話がまったく変わります。ビルドツールは、ビルド時点にこの変数の実際の文字列値を見つけ出し、クライアントJavaScriptバンドルファイルの中に文字どおりそのまま埋め込みます(Inlining、インライン化)。
// サーバー専用コード (Route Handler / Server Action) — 安全
// このコードはサーバーでのみ実行され、ブラウザバンドルには含まれない。
const apiKey = process.env.LLM_SECRET_API_KEY;
// クライアントコンポーネント ("use client") — 全世界に公開される!
// ビルド時に以下のコードは "https://proj.supabase.co" のような実際の文字列に置換され、
// 誰でもブラウザの開発者ツール (Sources/Network) から読み取ることができる。
const publicUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
ここで覚えておくべき2つの重要な性質があります。
- **
NEXT_PUBLIC_が付いた値は秘密(シークレット)ではありません。**名前をどれほどNEXT_PUBLIC_SECRET_KEYと名付けたところで、その値はWebページのHTMLソースに書き込んだ文字とまったく同じように、世界中の誰もが閲覧できます。したがって、LLM APIキー、データベース接続文字列、service_roleキー、Webhook署名検証シークレット、暗号化キーには、絶対にNEXT_PUBLIC_プレフィックスを付けてはいけません。 NEXT_PUBLIC_変数はビルド時点(Build-time)に固定されます。サーバー専用の環境変数はサーバーがリクエストを処理する際に読み取ることができますが、クライアントバンドルにインライン化されたNEXT_PUBLIC_の値はすでに静的ファイルとして焼き付けられてCDNに上がっているため、ダッシュボードで値を変更したあとは必ず再デプロイ(Redeploy)して新しくビルドしなければブラウザ側に反映されません。
注意
時折、クライアントコンポーネントでサーバー専用変数がundefinedになるという理由から、エラーを手っ取り早く消そうとして既存の秘密変数の先頭にNEXT_PUBLIC_を付けて解決しようとするケースがあります。これは、金庫の鍵が開かないからといって鍵をビルの正面玄関の掲示板にぶら下げておくようなものです。クライアント側で秘密鍵が必要な機能(例:LLMの呼び出し、決済の承認)は、必ずサーバーのAPIルートやサーバーアクションを経由するように構造を修正しなければなりません。
デプロイ保護(Deployment Protection)と自動化バイパス設計
Previewデプロイは、PRごとに固有のURL(例:my-app-git-feat-new-ui-team.vercel.app)を生成します。このURLは、アドレスさえ知っていればインターネット上のどこからでもアクセスできる公開エンドポイントです。もしデプロイ保護を有効にしなければ、まだセキュリティレビューが終わっていない新機能、内部管理画面の草案、あるいはリリース前のマーケティングページが外部にそのまま露出するだけでなく、検索エンジンのボットがそれをインデックス登録してしまうおそれもあります。
Vercelの**デプロイ保護(Deployment Protection)**は、Previewデプロイ(および本番のデフォルト.vercel.appドメイン)の手前に認証の関門を設ける機能です。
- Vercel Authentication:同じVercelチームに所属してログインしているメンバーだけがPreview URLにアクセスできるよう制限します。
- Password Protection:追加のパスワードを知っている外部の協力会社やレビュアーにだけ画面を開きます。
- Trusted IPs:社内の固定IPやVPNゲートウェイ帯域からのアクセスのみを許可します。
問題は、人間ではなく自動化ツールがPreview URLにアクセスしなければならないときに生じます。たとえばGitHub ActionsからPlaywrightを使ってデプロイ済みのPreview環境を対象にエンドツーエンド(E2E)テストを回したり、外部の決済・メッセージングサービスのテストWebhookがPreviewサーバーの/api/webhookを呼び出したり、バックエンドのCIパイプラインがヘルスチェックを送ったりすると、Vercel Authenticationのログイン画面(HTTP 401/403またはログインリダイレクト)に阻まれてすべて失敗してしまいます。
このとき、デプロイ保護を丸ごと無効化する代わりに使う定石の解決策が、**自動化のための保護バイパス(Protection Bypass for Automation)**です。プロジェクト設定でバイパスシークレット(VERCEL_AUTOMATION_BYPASS_SECRET)を発行してもらうと、自動化クライアントはリクエスト時に次のヘッダーを一緒に送ります。
GET /api/health HTTP/1.1
Host: my-app-git-feat-team.vercel.app
x-vercel-protection-bypass: <発行されたバイパスシークレット値>
x-vercel-set-bypass-cookie: true
x-vercel-protection-bypassヘッダーの値がプロジェクトのバイパスシークレットと一致すれば、Vercelエッジは認証画面をスキップしてリクエストを関数へと通過させます。- ブラウザベースのE2Eテスト(Playwrightなど)のように、最初のページに入ったあと複数のサブリソースやページを続けざまにリクエストする必要がある場合は、
x-vercel-set-bypass-cookie: true(マルチドメイン・クロスサイトテスト時はsamesitenone)ヘッダーを一緒に送ってバイパスCookieをセットしておけば、以降のリクエストも途切れることなく通過します。 - 外部のサードパーティWebhookのようにカスタムHTTPヘッダーを付与できないサービスの場合は、クエリパラメータ(
?x-vercel-protection-bypass=<シークレット>)方式を限定的に使うことができますが、URLがログに残る可能性があるためヘッダー方式を優先し、Webhook本文自体のHMAC署名検証を必ず併用しなければなりません。
関数リージョン配置、実行時間上限とFluid Compute・ストリーミング
静的ファイル(画像、CSS、バンドルされたJS)は世界中のCDNエッジノードにコピーされ、ユーザーに最も近い場所から即座に配信されます。しかし、データベースにクエリを投げ、LLM APIを呼び出す**サーバーレス関数(Serverless Function)**はどこに配置すべきでしょうか。
多くの開発者が「関数もユーザーに近い方が速いはずだ」と考えるか、あるいは何も設定せずにVercelのデフォルトリージョンである**米国東部(iad1、ワシントンD.C.)に関数を置いたままにします。ところがデータベース(Supabase Postgres)がソウル(ap-northeast-2、Vercelリージョンコードicn1)**にある場合、深刻な現象が起きます。
ソウルにいるユーザーがリクエストを送ったときの2つの構成を比較してみましょう。
| 構成 | リクエストの流れと往復距離 | DBクエリ5回実行時のネットワーク遅延 |
|---|---|---|
関数iad1(米国) + DBソウル |
ソウルのユーザー → 米国関数(180ms) → ソウルDBクエリ5回往復(5 × 180ms = 900ms) → ソウルのユーザー(180ms) | 約1,260ms以上(クエリが増えるほど激増) |
関数icn1(ソウル) + DBソウル |
ソウルのユーザー → ソウル関数(15ms) → ソウルDBクエリ5回往復(5 × 3ms = 15ms) → ソウルのユーザー(15ms) | 約45ms(データベースと関数が同じ圏域) |
1つのWebリクエストを処理するとき、ユーザーと関数の間の往復はたった1回だけですが、**関数とデータベースの間の往復は、認証確認、プロフィール照会、権限検査、履歴保存など、1つのリクエストの中で何回も直列(Waterfall)に発生します。したがって、サーバーレス関数のリージョンは必ずプライマリデータベース(Primary DB)に最も近いリージョン(例:韓国のDBならicn1)**に固定しなければなりません。vercel.jsonのregions: ["icn1"]という設定1つで、数百ミリ秒の遅延を即座に削り落とすことができます。
AIアプリケーションにおいては、リージョン配置と同じくらい関数の実行時間上限(maxDuration)とストリーミング、そしてFluid Computeが重要です。
- 実行時間上限(
maxDuration):一般的なCRUD APIは1秒以内に終わりますが、推論モデル(Reasoning Model)の呼び出しや多段階のツール実行エージェントは、応答が完了するまでに30秒〜数分かかります。ルートごとにexport const maxDuration = 60;(またはプランが許容する範囲内の上限)を明示しておかないと、モデルが懸命に回答を生成している最中に関数がタイムアウトで強制終了され、ユーザーは真っ白な画面を見てトークン費用だけが無駄になってしまいます。 - ストリーミング(Streaming、SSE / ReadableStream):LLMが最初のトークンを出力した瞬間にブラウザへと流し始めれば、最初の1バイトが届くまでの時間(TTFB)と体感遅延を大幅に短縮でき、接続が生きていることを維持して中間プロキシのアイドルタイムアウトを回避できます。
- Fluid Compute(流体コンピューティング):従来型のサーバーレス関数は1リクエストにつき1インスタンスが割り当てられ、その関数がOpenAIやAnthropic APIからの遅いストリーミング応答をひたすら待っている(I/O待機)間もCPUとメモリを独占してコストを消費していました。VercelのFluid ComputeはNode.js/Pythonランタイムの非同期I/O特性を活かし、1つのウォーム(Warm)な関数インスタンスがネットワークI/Oを待っている間に、他のリクエストを同時に(Concurrency)処理できるようにしてくれます。その結果、コールドスタート(Cold Start)の頻度とI/O待機中のアイドル課金が劇的に減少します。また、すでに応答をユーザーへ返したあとに監査ログの記録や使用量の集計を完了させたい場合は、
waitUntil()APIを使うことで、ユーザーへの応答を遅らせることなくバックグラウンドの後処理をやり遂げることができます。
Supabase PostgresとSupavisorコネクションプーリング:6543対5432
PostgreSQLは伝統的に、クライアント接続(Connection)1つごとにサーバーOS上で別個のプロセス(Process)を立ち上げてメモリを割り当てるという重い接続モデルを採用しています。そのため、1台のデータベースが同時に維持できる直接接続数(max_connections)は、インスタンスのサイズに応じて数十本から数百本程度に厳しく制限されています。
従来型の常駐型サーバー(例:EC2 2台)の環境であれば、各サーバーが起動時に接続を10本ずつだけ開いて使い回すため(合計20本)、何の問題も起きません。しかしVercelのようなサーバーレス環境では、トラフィックが集中した瞬間に関数インスタンスが数十〜数百個へと膨れ上がり、各インスタンスがPostgreSQLへ直接TCP+TLSハンドシェイクを結ぼうとすると、接続上限があっという間に底をついてしまいます。
この問題を解決するために、Supabaseは前段にSupavisorというクラウドネイティブのコネクションプーラー(Connection Pooler)を配置しています。Supavisorは数千本の軽量なクライアント接続を受け止め、背後のPostgreSQLには少数の実接続だけを維持してクエリを中継します。このときポート番号によって2つのモードを提供しており、この2つの違いを区別することが試験でも実務でも核心となります。
| 比較項目 | トランザクションモード (Transaction Mode) | セッションモード (Session Mode) | 直接接続 (Direct Connection) |
|---|---|---|---|
| デフォルトポート | 6543 (Supavisor) | 5432 (Supavisorプーラーホスト) | 5432 (DB直接ホスト、IPv6デフォルト) |
| バックエンド接続の占有単位 | 単一トランザクション(BEGIN〜COMMIT)の間だけ借りて使い、即座にプールへ返却 |
クライアントが接続を切るまでバックエンド接続を1対1で独占 | プーラーなしでPostgresプロセスに1対1で直接接続 |
| 適したワークロード | サーバーレス関数(Vercel、Edge、Lambda)、高同時実行Web API | 常駐型バックエンドサーバー、セッション状態(SET、一時テーブル)がどうしても必要な長期ジョブ |
スキーママイグレーション(CREATE/ALTER)、pg_dump、管理ツール |
| セッションレベル機能の制約 | 接続がトランザクションごとに変わるため、セッションレベルのPREPARE(Prepared Statement)、SET、LISTEN/NOTIFY、セッション一時テーブルが維持されない |
セッションレベル機能はすべて正常動作(ただし同時接続数の上限は直接接続と同じくらい早く埋まる) | すべてのPostgres機能が使用可能 |
サーバーレスアプリケーションでSupavisorを使う際に必ず守るべき実務ルールは3つあります。
- **ランタイムのアプリトラフィックにはポート6543(トランザクションモード)を使います。**関数が何百個立ち上がっても、実際にクエリを実行する数ミリ秒の間だけデータベース接続を借りて使い、即座に返却するため、接続枯渇を防ぐことができます。
- **ORM・ドライバーでPrepared Statementキャッシュを無効化するか、互換オプションを有効にします。**トランザクションモードでは、最初のクエリで作った
PREPARE stmt_1が保持されているバックエンド接続と、2番目のクエリに割り当てられたバックエンド接続が異なる場合があり、prepared statement "s1" does not existというエラーが発生することがあります(例:Prismaでは接続文字列に?pgbouncer=trueを付与し、postgres.jsではprepare: falseを指定します)。 - **データベースマイグレーション(
migrate)はセッションモードまたは直接接続(5432)に分離します。**多くのマイグレーションツールは、同時に2つのマイグレーションが実行されるのを防ぐためにPostgreSQLのセッションレベルのアドバイザリロック(pg_advisory_lock)を使ったり、長時間のDDL作業を行ったりします。したがって、ランタイム用のDATABASE_URL(6543)とマイグレーション専用のDIRECT_URL(5432)を分けて管理するのが定石です。もしトランザクションモードの中で設定値を注入する必要がある場合は、セッション全体に残ってしまうSETの代わりに、現在のトランザクション内でのみ有効でコミット時に自動初期化されるSET LOCAL(例:SET LOCAL role、SET LOCAL app.user_id = ...)を使用し、他のリクエストへ状態が漏れ出さないようにしなければなりません。
Row Level Security(RLS):USINGとWITH CHECKの正確な動作原理
SupabaseはPostgreSQLの上に自動でREST/GraphQL API(PostgREST)を生成し、ブラウザやモバイルアプリから直接データを読み書きできるようにしてくれます。非常に便利な機能ですが、その分だけデータベースのテーブル自体がインターネットに対する最初の防衛線になります。もしテーブルを作成して**Row Level Security(RLS、行レベルセキュリティ)**を有効にしなければ(ALTER TABLE ... ENABLE ROW LEVEL SECURITYの未実行)、公開されているanonキーを持っているだけの誰でも、そのテーブルのすべての行を読み取り、変更し、削除することができてしまいます。
RLSを有効にした瞬間、そのテーブルは**デフォルト拒否(Default Deny)**の状態になります。つまり、ポリシー(Policy)によって明示的に許可されていない操作は、1行たりとも見えず、書き込むこともできません(ただし、テーブル所有者やBYPASSRLS権限を持つservice_roleは例外です)。
RLSポリシーを設計するときに最も混同しやすいポイントが、USING句と**WITH CHECK句の役割の違いです。たとえるなら、USINGは「倉庫からどの品物を取り出して見ることができるか(既存行のフィルタ)」であり、WITH CHECKは「倉庫に新しく入れる、または直して入れる品物が合格品であるか(新規行の検証)」**です。
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
-- 照会 (SELECT): 自分が所有する行だけを見ることができる。
CREATE POLICY "docs_select_own" ON public.documents
FOR SELECT TO authenticated
USING ( (SELECT auth.uid()) = owner_id );
-- 作成 (INSERT): 自分の名前でのみ新しい行を入れることができる。
CREATE POLICY "docs_insert_own" ON public.documents
FOR INSERT TO authenticated
WITH CHECK ( (SELECT auth.uid()) = owner_id );
-- 更新 (UPDATE): 自分の行だけを選んで (USING)、更新後も依然として自分の所有でなければならない (WITH CHECK)。
CREATE POLICY "docs_update_own" ON public.documents
FOR UPDATE TO authenticated
USING ( (SELECT auth.uid()) = owner_id )
WITH CHECK ( (SELECT auth.uid()) = owner_id );
-- 削除 (DELETE): 自分が所有する行だけを消すことができる。
CREATE POLICY "docs_delete_own" ON public.documents
FOR DELETE TO authenticated
USING ( (SELECT auth.uid()) = owner_id );
コマンドごとに2つの句がどのように動作するかを表にまとめると、次のようになります。
| SQLコマンド | USING句の役割(既存行の検査) |
WITH CHECK句の役割(新規行/変更後の行の検査) |
よくある設計ミス |
|---|---|---|---|
SELECT |
条件が真(true)の行だけを結果に含める(偽の行は静かに隠す) |
使用不可(新しく生じる行がない) | RLSをそもそも有効にしないか、USING (true)で機密テーブル全体を全公開してしまう |
INSERT |
使用不可(比較対象となる既存の行がない) | 新しく挿入しようとする行が条件を満たさない場合はエラーとして拒否 | WITH CHECK (true)にしてしまい、他人のowner_idを騙って行を挿入される |
UPDATE |
更新対象となる既存の行をまず絞り込む | 更新が終わったあとの新しい状態が条件を満たすかを再検証 | USINGだけを書いて変更カラムを統制しないか、所有権を他人へ書き換えられてしまう |
DELETE |
削除できる既存の行を絞り込む | 使用不可(残る新しい行がない) | SELECTポリシーさえあれば削除も防げると錯覚するか、条件を書き漏らす |
参考
UPDATEポリシーでUSING句だけを記述してWITH CHECK句を省略すると、PostgreSQLはデフォルトでUSING句の条件をWITH CHECKにもそのままコピーして適用します。しかし実務では、「下書き(status = 'draft')である自分の記事だけを修正できるが(USING)、修正後の状態は下書きまたは提出済み(status IN ('draft', 'submitted'))でなければならず、いきなり承認済み(approved)状態に変えることはできない(WITH CHECK)」というように、更新前の条件と更新後の条件が異なるケースが多いため、両方の句を明示的に区別して書く習慣をつけるのが安全です。また、(SELECT auth.uid()) = owner_idのようにauth.uid()をスカラサブクエリで囲み、owner_idにインデックスを張っておけば、PostgreSQLのオプティマイザが関数呼び出しの結果を1回だけ評価(InitPlan)するため、大量の行を照会するときの性能が大幅に向上します。
anonキーとservice_roleキーの信頼境界
Supabaseプロジェクトには、役割がまったく異なる2種類のAPIキー(または新しいPublishableキーとSecretキーの体系)があります。
anonキー(Publishable Key):ブラウザやモバイルアプリに埋め込んでもよいように設計された公開キーです。ユーザーがログインする前はPostgreSQLのanonロールとして動作し、ユーザーがSupabase AuthでログインしてJWTアクセストークンを受け取ると、リクエストがauthenticatedロールへと切り替わります。どちらの場合でも、すべてのクエリはRLSポリシーの統制を厳格に受けます。つまり、anonキーが外部に露出すること自体は正常であり、セキュリティはキーの秘匿性ではなくRLSポリシーの完全性にかかっています。service_roleキー(Secret Key):バックエンドの管理作業、Webhookの受信処理、バッチ集計のために提供される最高権限の秘密鍵です。このキーでリクエストを送ると、PostgreSQLの**BYPASSRLS権限が作動してテーブルのすべてのRLSポリシーを完全に無視し**、あらゆる行を読み書き・削除できるようになります。
したがって、service_roleキーはデータベースのルートマスターキーにほかなりません。
- 絶対にブラウザ(
NEXT_PUBLIC_)、モバイルアプリのバイナリ、公開Gitリポジトリに含めてはいけません。 - バックエンドサーバーの中で使うとしても、一般ユーザーのリクエストを代行処理する経路でうっかり
service_roleクライアントを共用してしまうと大変なことになります。service_roleを使った瞬間にRLSという安全網が外れるため、開発者がコード内のWHERE user_id = ?という条件を1つ誤って書き漏らしただけで、他の顧客のデータがそのまま返されてしまいます(混乱した代理人、Confused Deputy問題)。 - ユーザーリクエストを処理するサーバーコードは、ユーザーのセッションを受け取ってRLSが適用されるクライアント(またはトランザクションローカルコンテキスト)でクエリを実行し、
service_roleは決済Webhookの反映やシステムCronジョブのように、ユーザーセッションなしでサーバー自身が主体とならなければならない最小限の経路にのみ隔離して使用すべきです。
Supabase Authコールバック許可リストとモバイル・Web認証フロー
ソーシャルログイン(Google、Apple、GitHub)やメールマジックリンク(Magic Link)・パスワードリセットリンクを使うとき、認証を終えたユーザーはサービスが指定したアドレス(redirectTo)へとトークンや認証コードを持って戻ってきます。
もし攻撃者がログインリクエストのredirectTo=https://evil.example.com/callbackパラメータを勝手に書き換えることができ、かつ認証サーバーがそれを検査せずにそのまま許可してしまったら(Open Redirect脆弱性)、被害者が正常にログインを終えた瞬間にアクセストークンや認証コードが攻撃者のサーバーへとそっくり渡ってしまいます。
これを防ぐため、Supabase Authでは2つの設定を強制しています。
- Site URL:
redirectToが指定されていないときに戻るデフォルトの本番オリジン(例:https://aidan.agentcat.app)。 - Redirect URLs(許可リスト、Allowlist):
redirectToとして許可する正確なURLまたはパターンの一覧です。ここに登録されていないアドレスへのリダイレクトを要求すると、Supabase Authはそれを拒否するか、デフォルトのSite URLへと送り返します。
Webとモバイルアプリを併用して運用するサービスでは、この許可リストを精緻に管理しなければなりません。
| 環境・プラットフォーム | 許可リストの登録例 | 注意点 |
|---|---|---|
| 本番Web | https://app.example.com/** |
http://ではなくhttps://を明示し、サブドメインワイルドカード(https://*.example.com/**)は攻撃者が制御可能なサブドメインがない場合にのみ限定的に使用する |
| PreviewデプロイWeb | https://*-myteam.vercel.app/** |
https://*.vercel.app/**のように広く開けすぎると他人のVercelアプリへトークンが渡るおそれがあるため、プロジェクト・チームの範囲に限定する |
| モバイルネイティブアプリ | myapp://auth/callback |
カスタムURLスキーム(Deep Link)またはUniversal Links / App Linksを正確なパスで登録する |
| ローカル開発環境 | http://localhost:3000/** |
ローカル開発用のアドレスが本番環境のデフォルトSite URLにならないよう注意する |
また、サーバーサイドレンダリング(SSR)とモバイルアプリが共存する場合は、使い捨ての認証コード交換方式である**PKCE(Proof Key for Code Exchange)**フローを基本として使用します。URLハッシュ(#access_token=...)にトークンを直接詰めて送るインプリシット(Implicit)フローとは異なり、PKCEでは認証を開始したクライアントが生成したcode_verifierがなければ使い捨てのcodeをセッショントークンに交換できないため、途中で傍受されるリスクを大幅に減らすことができます。
Supabase Storage:公開バケット対非公開バケットと署名付きURL(Signed URL)
AIサービスでは、ユーザーがアップロードしたPDF文書、音声録音、身分証・資格証の写し、生成された成果物画像を頻繁に扱います。Supabase Storageは、ファイル自体はオブジェクトストレージ(S3互換)に保存し、誰がどのバケットのどのパス(bucket_id、name)にアクセスできるかについては、PostgreSQLのstorage.objectsテーブルに設定されたRLSポリシーによって統制します。
バケットを作成する際は、まず**公開バケット(Public Bucket)と非公開バケット(Private Bucket)**を選択します。
- 公開バケット(
public = true):バケット内のファイルパス(URL)さえ知っていれば、認証ヘッダーやRLS照会なしでも、誰でもCDNを通じて即座にダウンロードできます。サービスロゴ、公開コースの表紙、誰でも見られる共有バッジ画像のように、機密性がまったくないアセットにのみ使うべきです。ファイル名を推測しにくいUUIDにしたから公開バケットに契約書を入れても安全だと考えるのは危険です(URLがブラウザ履歴、リファラヘッダー、ログを通じて1度でも漏れれば永久に閲覧可能になります)。 - 非公開バケット(
public = false):すべてのダウンロード・アップロードリクエストに認証が必要であり、storage.objectsのRLSポリシーを通過しなければなりません。顧客がアップロードした元文書、個人別の成績表PDF、試験中に撮影された写真など、権限のある当事者だけが見るべきすべてのファイルは必ず非公開バケットに置かなければなりません。
では、非公開バケットに入っているファイルをログイン済みのユーザーへ安全に配信したり、外部のパーサー(Parser)ワーカーに一時的に読み取らせたりするにはどうすればよいでしょうか。大きく2つのパターンを使います。
- ユーザーセッションベースのRLS + パス規則:ファイルパスを
{user_id}/{document_id}.pdfのように規則的に保存し、storage.objectsテーブルに(storage.foldername(name))[1] = (SELECT auth.uid()::text)という条件のRLSポリシーを設定して、本人のフォルダ内のファイルだけを読み書きさせます。 - 時間制限のある署名付きURL(Signed URL):サーバー側でユーザーの権限を確認したあと、
createSignedUrl(path, 60)のように短い有効期限(例:60秒〜5分)が刻まれた暗号化署名付きURLを発行してクライアントへ渡します。このURLは有効期限が過ぎると自動的に無効になるため、万が一リンクが流出したとしても露出する時間の窓(ウィンドウ)を最小限に抑えることができます。大容量ファイルをクライアントがサーバーを経由せずにストレージへ直接アップロードするときも、**署名付きアップロードURL(createSignedUploadUrl)**を発行すれば、サーバーレス関数のペイロードサイズ制限(例:Vercelの4.5MBリクエストボディ上限)を回避しつつ権限統制もしっかり守ることができます。
さらに深く
サーバーレスのコールドスタート、DBのTLSハンドシェイクとトランザクションローカルコンテキスト
なぜサーバーレス関数とPostgreSQLを接続するときにリージョン間の距離とプーラーのモードがそれほど致命的になるのか、ネットワークパケットのレベルで覗いてみましょう。
1つの関数インスタンスが新しく起動(Cold Start)し、リモートのPostgreSQLへ新しい接続を確立するためには、次の手順を順番に踏まなければなりません。
- DNSクエリによってデータベースホストのIPアドレスを調べます。
- TCP 3ウェイハンドシェイク(
SYN→SYN-ACK→ACK)で1往復(1 RTT)を費やします。 - PostgreSQLの
SSLRequestネゴシエーションおよびTLS 1.2/1.3ハンドシェイクで証明書を検証し、共通鍵を交換するのにさらに1〜2往復(1〜2 RTT)を費やします。 - PostgreSQLのスタートアップパケット送信とSCRAM-SHA-256認証(Challenge-Response)で2往復(2 RTT)を費やします。
- ここでようやく最初の
SELECTクエリを送り、結果を受け取ります(1 RTT)。
つまり、最初のクエリを1つ実行するために最低でも**5〜6回のネットワーク往復(RTT)**が必要になります。関数(iad1)とDB(icn1)の間の1 RTTが180msだとすると、たとえクエリ自体が1msで終わるインデックス照会であったとしても、**接続を結ぶだけで1秒(1,000ms)が吹き飛んでしまいます。**一方、関数をicn1に置いて1 RTTを2〜3msに縮め、さらにSupavisorが背後のPostgreSQLとの重い接続をあらかじめ確立した状態で待機していれば、この接続確立コストは数十ミリ秒以内に圧縮されます。
ここにもう1つセキュリティが加わります。リモートデータベースに接続する際は、中間者攻撃(MITM)を防ぐために必ず**TLS暗号化とサーバー証明書の検証(rejectUnauthorized: trueおよびプロバイダーのルートCA証明書の指定)**を強制しなければなりません。接続がうまくいかないからといってコードにssl: { rejectUnauthorized: false }を入れるのは、攻撃者が偽のデータベースサーバーになりすましていても暗号化通信を結ぶという意味になるため、本番環境では絶対に禁物です。
また、独自のセッション管理やサーバーサイド認証を使いながら、PostgreSQLのRLSやトリガーに現在のリクエスト送信者の身元を渡さなければならない場合があります。先ほど見たように、Supavisorのトランザクションモード(6543)で通常のSET app.current_user_id = 'u_123'を実行すると、その設定がコネクションプールの接続にそのまま残り、**次にリクエストを送ってきたまったく別のユーザーに自分の権限が渡ってしまうという重大なセキュリティ事故(セッション汚染)が起きます。したがって、トランザクションプーラーの背後では、必ず1つのBEGIN ... COMMITブロックの中でSET LOCAL(またはset_config('...', '...', true))**を使用し、トランザクションが終わった瞬間に設定値が自動的に消滅するように作らなければなりません。
マルチテナントAIサービスにおけるRLSの性能の落とし穴とバイパスパターンの点検
RLSは強力なセキュリティ装置ですが、ポリシーの条件式を誤って記述すると、テーブルの行数が10万件を超えただけでもデータベースのCPUが100%に跳ね上がり、サービス全体が遅くなります。なぜならPostgreSQLは、RLSの条件式を**すべての照会クエリのWHERE句に強制的に付け加えるセキュリティバリア(Security Barrier)**として実行するからです。
最もよくある2つの性能の落とし穴と解決策を比較してみましょう。
| パターン | 遅いRLSの記述例 | 速いRLSの最適化解決策 | 原理の違い |
|---|---|---|---|
| 関数の反復呼び出し | USING ( auth.uid() = user_id ) |
USING ( (SELECT auth.uid()) = user_id ) |
スカラサブクエリで囲むと、行ごとに関数を再呼び出しせず、クエリあたり1回だけ評価(InitPlan)したあとにインデックススキャンを実行する |
| 多対多の所属結合 | USING ( team_id IN (SELECT team_id FROM members WHERE members.user_id = auth.uid()) )でインデックスなし |
members(user_id, team_id)複合インデックスの作成 + SECURITY DEFINERヘルパー関数またはJWTクレームの活用 |
結合対象カラムにインデックスがないと、毎回フルテーブルスキャン(Seq Scan)が発生する |
ここでSECURITY DEFINER関数を使う際には格別の注意が必要です。PostgreSQL関数のデフォルトは、呼び出し元の権限とRLSにそのまま従うSECURITY INVOKERです。一方、SECURITY DEFINERは関数を作成した人(通常は管理者)の権限で実行され、RLSをバイパスします。複雑な所属確認を高速に処理しようとしてSECURITY DEFINER関数を作ったものの、その関数が任意の引数を受け取ってテーブルデータをそのまま返し、なおかつpublic/anonロールに実行権限(EXECUTE)が開かれていたとしたら、攻撃者はRPC呼び出し1つでRLSを完全にバイパスして全データを抜き出すことができます。したがって、SECURITY DEFINER関数を使うときはSET search_path = ''(スキーマハイジャック防止)を明示し、戻り値を厳格な真偽値(boolean)や最小限の識別子に制限し、不要なロールのEXECUTE権限をREVOKEしなければなりません。
実務の場面
ハンビットエデュ:リリース直前に発覚した環境変数流出とSupavisor接続枯渇
教育技術スタートアップの「ハンビットエデュ」は、受講生の論述答案を添削するWebサービスをNext.js(Vercel)とSupabase(ソウルリージョン)で開発した。リリースの3日前、模擬論述大会を開き、500人の学生が同時に答案を提出してリアルタイムストリーミング添削を受ける負荷テストを実施した。
負荷テスト開始から3分で、2つの致命的な問題が発見された。第1に、セキュリティ点検を担当していたエンジニアがブラウザでページソースと_next/static/chunks/配下のJavaScriptバンドルを検索していたところ、NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEYという文字列とともに実際のservice_role JWTが丸ごと入っているのを見つけた。ジュニア開発者が学生のスコア集計画面でRLS権限エラーに遭遇した際、クライアントコードから直接集計を回そうとして、service_roleキーにNEXT_PUBLIC_を付けて入れてしまったのである。第2に、学生150人が同時に添削ボタンを押すと応答が10秒ずつ遅延し、ついには半分以上のリクエストが500エラー(too many clients already、FUNCTION_INVOCATION_TIMEOUT)で失敗した。
インフラリードは即座にアーキテクチャを作り直した。
- キーの回収および境界の再設定:すでにクライアントバンドルへ1度でも露出した
service_roleキーは流出したものとみなし、直ちにSupabaseで秘密鍵を再発行(ローテーション)した。クライアントにはNEXT_PUBLIC_SUPABASE_URLとNEXT_PUBLIC_SUPABASE_ANON_KEYだけを残し、スコア集計はサーバー専用のRoute Handlerへと移したうえで、service_roleの代わりに適切なRLSポリシーと検証済みサーバークエリで処理するように修正した。 - 関数リージョンと実行時間の調整:確認してみたところ、Vercel関数のリージョンがデフォルト値の米国東部(
iad1)になっており、ソウルのSupabase DBと毎回太平洋を横断して通信していた。vercel.jsonに"regions": ["icn1"]を指定して関数をソウルへ移し、添削ストリーミングルートにexport const maxDuration = 60;とFluid Computeを有効化した。 - Supavisorトランザクションモードへの切り替え:
DATABASE_URLがポート5432(セッションモード)を指していたため、同時実行された関数群が接続をすぐに使い果たしていた。ランタイムの接続文字列をポート6543(Supavisorトランザクションモード)に変更し、ドライバーのPrepared Statementオプションをオフにした。スキーママイグレーションスクリプトだけは別個のDIRECT_URL(ポート5432)を使うように分離した。
翌日に改めて実施した500人同時負荷テストでは、データベースのアクティブ接続数が15本前後で安定して維持され、最初の添削文が画面に表示されるまでの時間(TTFB)が平均4.2秒から0.8秒へと短縮された。
チョンリュバイオ:非公開研究ノートの流出を防いだRLS WITH CHECKと署名付きアップロードURL
新薬候補物質の文献をAIで分析する「チョンリュバイオ」は、研究所ごとに独立したワークスペースを提供するB2B SaaSを運用している。各研究員は実験結果のPDF(最大30MB)をアップロードし、要約ノートを作成する。初期バージョンでは2つの苦情が寄せられた。
第1に、10MBを超えるPDFをアップロードするたびに、VercelのAPIルートで413 Payload Too Largeエラーが出た。クライアントがファイルをまずVercelのサーバーレス関数へ送り、関数が再びSupabase Storageへアップロードする構造になっていたため、Vercel関数のリクエストボディサイズ制限に引っかかったのである。第2に、内部のペネトレーションテストにおいて、A研究所のアカウントでログインしたあとブラウザのコンソールからsupabase.from('notes').update({ lab_id: 'lab_B' }).eq('id', myNoteId)を呼び出したところ、自分のノートがB研究所の一覧へとそのまま移ってしまう現象が発見された。
プラットフォームチームは原因を分析し、次のように修正した。
| 問題領域 | 既存設計の抜け穴 | 改善された設計 |
|---|---|---|
notesテーブルのRLS |
FOR UPDATE USING (lab_id = current_user_lab())しかなくWITH CHECKに対する不変性統制がなかったか、またはINSERTポリシーがWITH CHECK (true)で開いており他研究所のlab_idを注入可能だった |
INSERTとUPDATEの両方にWITH CHECK (lab_id = current_user_lab())を明示し、さらにトリガーで作成後のlab_id変更自体を遮断 |
| 大容量PDFのアップロード | ブラウザ → Vercel関数(4.5MB上限) → Supabase Storage中継 | サーバーで権限検証後にcreateSignedUploadUrlを発行 → ブラウザがSupabase Storageの非公開バケットへ直接アップロード |
| PDFの閲覧 | 便宜上StorageバケットをPublicで開けておきURLだけを隠していた |
バケットをPrivateに切り替え、閲覧時にサーバーが所属研究所を確認したうえで**60秒有効期限の署名付きURL(createSignedUrl)**を発行 |
当初は誰かが「クライアントから直接Storageへ上げられるようにバケットをPublicにしよう」と提案したが、研究データの流出リスクがあるため即座に却下された。非公開バケットとサーバーが発行する使い捨ての署名付きアップロードURLの組み合わせのおかげで、サーバーレス関数のボディサイズ制限を回避しつつ、「権限のある研究員だけが自分の研究所のパスにファイルを上げて読む」というセキュリティ境界を完璧に守ることができた。
間違えやすいポイント
- **「
NEXT_PUBLIC_環境変数はVercelのダッシュボードで暗号化保存されるため、ブラウザでも安全である。」**ダッシュボードで暗号化されるのはビルド前の保管状態にすぎません。NEXT_PUBLIC_が付いた変数は、ビルドされた瞬間にJavaScriptファイルの中に平文の文字列として埋め込まれ、世界中のユーザーのブラウザへと配信されます。シークレット値には絶対に使ってはいけません。 - **「
NEXT_PUBLIC_変数の値をダッシュボードで変更したから、即座にサイトへ反映される。」**サーバー専用変数とは異なり、クライアントバンドルにインライン化された変数はビルド時点で固定されます。値を変更したあとは、必ず新しいビルドを再デプロイ(Redeploy)しなければ反映されません。 - **「サーバーレス関数はCDNのように世界中のすべてのリージョンへ同時にデプロイするほど常に速くなる。」**関数が単一リージョンのデータベース(例:ソウル)に何度もクエリを投げる構造である場合、関数がDBから遠く離れたリージョンで実行されると、関数↔DB間の多重往復(RTT)遅延が激増します。関数はプライマリデータベースに近いリージョン(
icn1)に配置するのが原則です。 - **「ポート5432がPostgreSQLの標準ポートなのだから、サーバーレスアプリでも5432を使えばよい。」**サーバーレス関数のように寿命の短いインスタンスが急激に増減する環境でセッションモードや直接接続(5432)を使うと、接続スロットがあっという間に枯渇します。ランタイムトラフィックにはSupavisorのトランザクションモード(6543)を使い、マイグレーションだけを5432に分離しなければなりません。
- **「テーブルに
SELECTのRLSポリシーさえしっかり設定しておけば、他のユーザーがデータを変更したり消したりすることはできない。」**RLSを有効にしたテーブルでは、コマンドごとにポリシーが評価されます。SELECTポリシーは照会を制御するだけであり、もしINSERTやUPDATEポリシーを作るときにWITH CHECK (true)のように甘く開けておけば、他人の所有者IDでデータを押し込んだり上書きしたりできてしまいます。 - **「ファイル名を推測しにくい長いUUIDにすれば、Publicバケットに非公開文書を入れても安全である。」**アドレスの推測が難しいということ(Security by Obscurity、隠蔽によるセキュリティ)はアクセス統制ではありません。公開バケットのURLは認証なしで開けるため、ログ、ブラウザ履歴、共有リンクから1度でも漏れ出せば回収する手段がありません。機密ファイルは必ず非公開バケットと短い有効期限の署名付きURLで扱わなければなりません。
ひと目でわかるまとめ
| 重要テーマ | 必ず覚えておくべき設計原則 |
|---|---|
| 環境分離 | PreviewとProductionはDB、ストレージ、外部APIキーを徹底的に分離し、本番シークレットはProductionにのみ注入する |
NEXT_PUBLIC_の境界 |
ビルド時にクライアントJSバンドルへ平文でインライン化される。秘密鍵・service_roleキーには絶対禁止であり、変更時は再デプロイが必要である |
| デプロイ保護とバイパス | PreviewデプロイはVercel Authenticationなどでロックし、CI E2E・Webhookはx-vercel-protection-bypassヘッダーで通過させる |
| 関数リージョンと実行 | 関数はプライマリDBと同じリージョン(例:ソウルicn1)に配置し、長いLLM呼び出しにはmaxDuration・ストリーミング・Fluid Computeを構成する |
| Supavisor 6543 vs 5432 | サーバーレスのランタイムはトランザクションモード(6543、Prepared Statementに注意)、スキーママイグレーション・セッションロックはセッション/直接接続(5432)を使う |
RLS USING vs WITH CHECK |
USINGは読み取りや更新・削除の対象となる既存行を絞り込み、WITH CHECKはINSERT/UPDATEで入る新しい行の状態を検証する |
anon vs service_role |
anonキーは公開前提でありRLSがセキュリティ境界となる。service_roleはRLSをバイパス(BYPASSRLS)するためサーバーの最小経路にのみ隔離する |
| Authリダイレクト許可リスト | オープンリダイレクト・トークン奪取を防ぐため、Webオリジンとアプリのディープリンク(myapp://...)を正確な許可リストで管理し、PKCEを使う |
| Storageアクセス制御 | 機密ファイルは非公開(Private)バケットに置き、閲覧・大容量直接アップロードには有効期限の短い署名付きURL(Signed URL)を発行する |
公式ドキュメントで確認する
- Vercel — Deployments & Environments:Production、Preview、Development環境の違いと環境別変数の分離原則。
- Vercel — Environment Variables:サーバー専用変数とフレームワーク公開プレフィックス(
NEXT_PUBLIC_)のビルドインライン化動作。 - Vercel — Deployment Protection & Protection Bypass for Automation:
x-vercel-protection-bypassヘッダーとCookieの設定方法。 - Vercel — Configuring Functions & Fluid Compute:関数リージョン(
icn1)、maxDuration、ストリーミングとFluid Computeの同時実行モデル。 - Supabase — Connecting to Postgres & Supavisor:トランザクションモード(6543)とセッションモード(5432)、サーバーレスORM設定。
- Supabase — Row Level Security (RLS):
USINGとWITH CHECKの構文、(SELECT auth.uid())による性能最適化。 - Supabase — Auth Redirect URLs および Storage Access Control:リダイレクトのワイルドカード規則と非公開バケットの署名付きURL。
セルフチェック
1-1. Next.jsアプリケーションをVercelにデプロイするとき、NEXT_PUBLIC_プレフィックスが付いた環境変数と付いていない環境変数がビルドおよびランタイムにおいてどのように異なって処理されるかを説明し、ダッシュボードでNEXT_PUBLIC_API_BASEの値を変更した際にブラウザへ反映させるために必ず行わなければならない作業を書きなさい。
1-2. プライマリデータベース(Supabase Postgres)がソウルリージョンにあり、主要なユーザーも韓国にいるWebサービスにおいて、Vercelサーバーレス関数のリージョンがデフォルト値の米国東部(iad1)に設定されているとき、1回のHTTPリクエストの中で遅延時間が急激に延びる理由をネットワーク往復(RTT)の観点から説明しなさい。
1-3. Supavisorのトランザクションモード(ポート6543)とセッションモード(ポート5432)がバックエンドデータベース接続を借りて使い返却するタイミングを比較し、サーバーレスアプリのランタイムクエリとデータベーススキーママイグレーションにそれぞれどちらのモードを使うべきか、理由とともに書きなさい。
1-4. PostgreSQLのRow Level Security(RLS)においてUSING句とWITH CHECK句がそれぞれいつ評価されるかを区別し、UPDATEポリシーにUSINGだけが正しく書かれていてWITH CHECK条件が緩いときに発生しうるセキュリティ問題を例を挙げて説明しなさい。
1-5. PreviewデプロイでVercel Deployment Protectionが有効になっているとき、GitHub Actionsで実行される自動化E2Eテストスクリプトがデプロイ保護を無効化することなくPreview URLの複数ページを連続してテストできるようにするHTTPリクエストヘッダーを2つ書きなさい。
1-6. 設計課題:ユーザーがアップロードした個人の契約書PDF(最大25MB)を安全に保存し、本人のみが閲覧できるようにしたい。Vercel関数のリクエストサイズ制限を回避しつつセキュリティを守るために、(1)Supabase Storageバケットの公開設定、(2)アップロードフロー、(3)閲覧フローをそれぞれどのように設計すべきか記述しなさい。
実習課題
実習
環境変数・接続文字列のセキュリティ監査表を作成する(40分)
- 自分が運用中、または架空で設計したAI Webサービスの環境変数を10個書き出す(例:
NEXT_PUBLIC_SITE_URL、NEXT_PUBLIC_SUPABASE_URL、NEXT_PUBLIC_SUPABASE_ANON_KEY、SUPABASE_SERVICE_ROLE_KEY、DATABASE_URL、DIRECT_URL、OPENAI_API_KEY、STRIPE_WEBHOOK_SECRET、VERCEL_AUTOMATION_BYPASS_SECRET、SESSION_SECRET)。 - 各変数について、表形式で次の5つの欄を埋める:(ア)クライアントバンドルへの包含有無、(イ)Preview/Productionで値が異なるべきか、(ウ)露出時の被害シナリオ、(エ)正しい呼び出し場所(ブラウザ/サーバーRoute Handler/マイグレーションCLI/CIランナー)、(オ)値の変更時に再ビルドが必要か。
DATABASE_URLとDIRECT_URLに入るポート番号(6543 vs 5432)とクエリパラメータ(?pgbouncer=trueなど)を明記する。
完了基準: 10個の変数のうち、クライアントバンドルに入っても安全な変数3個と絶対に入ってはいけないサーバー専用変数7個が明確に区別されており、ランタイムプーラー接続とマイグレーション接続のポート・用途が分離されていること。
実習
マルチテナントテーブルのRLSポリシーとStorage署名付きURLフローを設計する(50分)
projects (id, org_id, owner_id, title, status)テーブルを定義するSQLを作成し、RLSを有効化(ENABLE ROW LEVEL SECURITY)する。authenticatedロールを対象に、SELECT、INSERT、UPDATE、DELETEの4つのポリシーをそれぞれ作成する。このときauth.uid()はスカラサブクエリ(SELECT auth.uid())の形式で記述し、UPDATEポリシーにはUSINGとWITH CHECKの両方を明示して、所有者(owner_id)や所属組織(org_id)を勝手に別の値へ変更できないよう防ぐ。- 非公開Storageバケット
project-filesにファイルをアップロードして読み取る際、サーバーAPIが何を検査したあとにどのメソッド(createSignedUploadUrl、createSignedUrl)で何秒間のURLを発行するか、4段階のシーケンスを記述する。
完了基準: 4つのSQLポリシーが構文どおりに記述されており、INSERTとUPDATEにおける他人のowner_id/org_idのなりすましがWITH CHECKで遮断され、非公開バケットのアップロード・ダウンロードがすべて時間制限付きの署名付きURLで統制されていること。