はじめに
モダンなWebアプリでは、こんな構成をよく見かけます。
- フロント / ホスティング: Vercel (Next.js)
- DB / 認証: Supabase
- Auth / Storage / 通知: Firebase
- バックエンド処理 / インフラ: GCP (Cloud Run, Cloud Functions)
サーバーレスで速く作れる一方、「どこに認可ロジックを置くか」を間違えると、個人情報が丸見えになります。
この記事では、
- なぜ「APIバイパス」が起きるのか
- DTOとRLSによる多層防御
- 各サービス層ごとの想定攻撃と防御
- GCPとAWSのIAM設計思想の違い
- Vercel特有の侵入経路
を整理します。
対象読者
- Supabase / Firebase を使い始めたが、RLSやSecurity Rulesを後回しにしている人
- BaaS構成でどこまで守ればいいか分からない人
- 個人情報を扱うサービスをこれから作る人
1. 最初に理解すべき「APIバイパス」
多くの人が想定している構成
クライアント → 自作API (認証・認可・DTO変換) → Supabase → DB
「APIで認証チェックしてるから安全」——これが最大の落とし穴です。
実際に起きること
SupabaseのURLとanon keyは、フロントエンドのJSバンドルに含まれます。つまり 公開情報 です。
攻撃者はDevToolsを開いてキーを抜き、あなたのAPIを完全に無視してこう叩けます。
curl 'https://xxxxx.supabase.co/rest/v1/users?select=*' \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6..." \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6..."
クライアント ─┐
│ ← ここを飛ばされる
自作API ───┤
↓
Supabase → DB ← 直接叩かれる
これが APIバイパス です。
API層に置いた認可ロジックは、API層を通らなければ意味がありません。
Firebaseも同様で、Firestoreへはクライアントから直接アクセスできます。
結論: 鍵は漏れる前提で設計する。最終防衛ラインはDB層に置く。
2. DTO と RLS —— 役割の違い
| DTO | RLS | |
|---|---|---|
| 層 | アプリケーション (API境界) | データベース (PostgreSQL) |
| 守るもの | 「どの列を返すか」 | 「どの行を返すか」 |
| バイパス | されうる | されない |
| 役割 | 過剰な情報露出の防止 | 不正アクセスの遮断 |
2-1. DTO: 列レベルの制御
エンティティをそのまま返さず、用途ごとにスキーマを分けます。
// テーブルの実体(絶対にそのまま返さない)
type UserRow = {
id: string;
email: string;
display_name: string;
phone_number: string;
ssn: string; // 機密
bank_account: string; // 機密
role: string;
created_at: string;
};
// 公開用
export type UserPublicDTO = Pick<UserRow, 'id' | 'display_name' | 'created_at'>;
// 本人用
export type UserPrivateDTO = UserPublicDTO & Pick<UserRow, 'email' | 'phone_number'>;
export const UserMapper = {
toPublic(u: UserRow): UserPublicDTO {
return { id: u.id, display_name: u.display_name, created_at: u.created_at };
},
toPrivate(u: UserRow): UserPrivateDTO {
return { ...UserMapper.toPublic(u), email: u.email, phone_number: u.phone_number };
},
};
select('*') してから絞るのではなく、そもそもSELECTする列を絞る方が安全です。
DTOは「万一取得してしまった時の最後のフィルタ」と考えるとよいです。
2-2. RLS: 行レベルの制御(こちらが本命)
-- まずRLSを有効化(これを忘れると全公開)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- 自分の行だけ読める
CREATE POLICY "read_own_user"
ON users FOR SELECT
USING (auth.uid() = id);
-- 自分の行だけ更新できる
CREATE POLICY "update_own_user"
ON users FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
-- 削除は禁止(論理削除のみ許可する運用)
CREATE POLICY "deny_delete"
ON users FOR DELETE
USING (false);
RLSを有効にすると、先ほどのcurl直撃は 空配列 を返すようになります。エラーではなく「行が存在しない」扱いになるのがポイントです。
ENABLE ROW LEVEL SECURITY を忘れたテーブルは、anon keyで全世界に公開されています。
Supabaseのダッシュボードで「Unrestricted」バッジが出ているテーブルは即対応してください。
2-3. 組み合わせた多層防御
┌──────────────────────────────┐
│ クライアント │
└───────────┬──────────────────┘
│
┌─────▼─────┐ ┌────────────────┐
│ API層 │ │ 攻撃者は │
│ + DTO変換 │ │ ここを飛ばせる │
└─────┬─────┘ └───────┬────────┘
│ │
┌─────▼──────────────────────▼────┐
│ Supabase RLS ← 最終防衛ライン │
└─────┬───────────────────────────┘
│
┌─────▼─────┐
│PostgreSQL │
└───────────┘
| 攻撃 | 防ぐ層 |
|---|---|
| APIをバイパスして直接クエリ | RLS |
| APIの実装ミスで機密列を返却 | DTO |
| 認可チェック漏れのエンドポイント | RLS |
3. Firebase: 想定攻撃と防御
| サービス | 想定攻撃 | 防御 |
|---|---|---|
| Authentication | メールアドレス列挙(存在確認APIの悪用) | Email Enumeration Protection を有効化 |
| IDトークンのリプレイ | トークン有効期限を短く、サーバー側で毎回検証 | |
| 匿名認証で作ったUIDを正規ユーザー扱い | 匿名→正規の昇格はサーバー側で明示的に処理 | |
| Firestore / RTDB | Security Rules未設定で全read/write | デフォルトDENY。allow read: if request.auth != null を最低ライン |
if true の書き残し |
Rules Playground + エミュレータでルールのユニットテスト | |
| ネストしたサブコレクションの権限漏れ | ワイルドカード{doc=**}の影響範囲を必ず確認 |
|
| Cloud Storage | 誰でもアップロード可能 → ストレージ枯渇/違法コンテンツ設置 | Storage Rulesでサイズ・Content-Type・認証を検証 |
| 署名付きURLの長期発行 | 有効期限は分〜時間単位 | |
| Cloud Functions | HTTPトリガーが無認証で公開 | Callable Functionを使い context.auth を必須チェック |
| 共通 | Bot / スクレイピングによる大量リクエスト | App Check を導入(正規アプリからのリクエストのみ許可) |
Firestore Rulesの最小構成例:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// デフォルトで全拒否
match /{document=**} {
allow read, write: if false;
}
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow update: if request.auth != null
&& request.auth.uid == userId
// roleの自己昇格を防ぐ
&& !request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['role']);
allow delete: if false;
}
}
}
4. GCP: 想定攻撃と防御
| サービス | 想定攻撃 | 防御 |
|---|---|---|
| Cloud Run / Functions |
--allow-unauthenticated のまま公開放置 |
--no-allow-unauthenticated + IAM invoker で制御 |
SSRF → メタデータサーバー(169.254.169.254)からトークン窃取 |
外部URLを受け取る処理はallowlist化、VPC-SC | |
| IAM | サービスアカウントに Editor / Owner を付与 |
最小権限。必要ならカスタムロール |
| SAのJSONキーがGitにコミットされる | キーレス化(Workload Identity Federation) | |
| Secret Manager | 権限が広く誰でもシークレット取得可 |
roles/secretmanager.secretAccessor を対象SAのみに |
| Cloud Storage | バケットが allUsers に公開 |
Organization Policy で Public Access Prevention を強制 |
| VPC / Firewall |
0.0.0.0/0 全開放 |
必要ポートのみ。内部通信はPrivate Google Access |
| 監査 | 侵入に気づけない | Cloud Audit Logs 有効化 + 異常検知アラート |
特に効くのは Workload Identity Federation です。
VercelやGitHub ActionsからGCPを叩く際、SAキーJSONを配らずに済むため「鍵の流出」という事故カテゴリごと消せます。
5. GCP と AWS の IAM、何が違うのか
同じ「IAM」という名前ですが、設計思想がかなり違います。
| 観点 | GCP IAM | AWS IAM |
|---|---|---|
| 付与の考え方 | リソース階層(組織→フォルダ→プロジェクト)にロールをバインド | プリンシパル(ユーザー/ロール)にポリシーをアタッチ |
| 記述形式 | 事前定義ロール中心 (roles/storage.objectViewer) |
JSONポリシーを自分で記述 |
| 継承 | 上位階層から自動継承。構造が直感的 | Organizations + SCP。継承より「上限の設定」に近い |
| Deny | Deny Policy(比較的新しい機能) | Explicit Deny が常に最優先(古くから強力) |
| 一時権限 | Workload Identity Federation(キーレス) | STS AssumeRole(一時クレデンシャル) |
| 粒度 | ロール単位でシンプル。IAM Conditions で条件付与 | ステートメント単位で極めて細かい |
まとめると
- GCP: 「階層 × 事前定義ロール」でシンプルだが、継承による意図しない権限拡大に注意
- AWS: 「JSONで自由自在」だが複雑さゆえに過剰権限を作りやすい
どちらにせよ事故の9割は「とりあえず広い権限を付けた」なので、最小権限 + 定期的な権限棚卸しが本質です。
6. Vercel: 侵入経路とリスク
| 経路 | リスク | 対策 |
|---|---|---|
NEXT_PUBLIC_ 誤用 |
Service Role Key等がJSバンドルに埋め込まれ全世界公開 | 機密値はprefixなしのサーバー専用変数に |
| API Routes / Edge Functions | 認証チェック漏れのエンドポイントが誰でも叩ける | 各Route冒頭でセッション/JWT検証を必須化 |
| Preview Deployments | PRごとの公開URLが本番と同じDBを参照 | Deployment Protection(パスワード/SSO)、Preview用に別環境の変数を設定 |
| CORS誤設定 |
Allow-Origin: * で任意オリジンからAPI利用可 |
オリジンをallowlist化 |
| ビルドログ / リポジトリ | ログやコミットに環境変数が残る |
.envは.gitignore、変数はDashboardで管理 |
| npm依存 | サプライチェーン攻撃でビルド時に環境変数が外部送信される | lockfile固定、npm audit、依存の追加を審査 |
| Rate Limit未設定 | 認証エンドポイントへの総当たり | Vercel Firewall / WAF、Rate Limiting |
Vercelで一番見落とされがちなもの
Preview Deployments です。
main → https://myapp.vercel.app (守っている)
PR #42 → https://myapp-git-feat-xxx.vercel.app (URLを知っていれば誰でも見られる)
Preview環境が本番のSupabase/Firebaseを向いていると、被害範囲は本番と同等です。
必ず Deployment Protection を有効にするか、Preview専用の環境変数を設定してください。
7. この構成での優先順位
全部やるのが理想ですが、リソースが限られるなら次の順です。
- Supabase RLS を全テーブルで有効化 —— anon keyは公開情報なので、これが無いと素通し
- Firebase Security Rules をデフォルトDENYに —— 同上
- GCP IAM の最小権限化 + キーレス化 —— 侵入後の被害範囲を限定
- Vercel の Preview保護 と 環境変数の棚卸し —— 事故の入口を塞ぐ
- DTO層の整備 —— 情報露出をさらに絞る
- 監査ログとアラート —— 気づける状態にする
チェックリスト
### Supabase
- [ ] 全テーブルで RLS が有効(Dashboardに「Unrestricted」が無い)
- [ ] SELECT / INSERT / UPDATE / DELETE それぞれにポリシーを定義
- [ ] Service Role Key はサーバーサイドのみで使用
- [ ] RPC (SECURITY DEFINER) の権限昇格を確認
### Firebase
- [ ] Security Rules がデフォルトDENY
- [ ] Rules のユニットテストがCIで回っている
- [ ] App Check を導入
- [ ] Storage のサイズ / Content-Type 制限
### GCP
- [ ] SAに Owner / Editor を付けていない
- [ ] SAキーJSONを配布していない(Workload Identity)
- [ ] Cloud Run / Functions が無認証公開になっていない
- [ ] Audit Logs 有効 + アラート設定
### Vercel
- [ ] NEXT_PUBLIC_ に機密値が入っていない
- [ ] Preview Deployment に保護がかかっている
- [ ] API Routes 全てで認証チェック
- [ ] Rate Limiting 設定済み
おわりに
BaaS構成のセキュリティは、**「認可をどこに置くか」**の一点に尽きます。
- API層の認可はバイパスされうる
- だから RLS / Security Rules(DB層)が最終防衛ライン
- DTOやIAMは、それを補強する多層防御
「anon keyは漏れる前提」——ここから設計を始めれば、大きな事故はかなり減らせるはずです。
参考
- Supabase | Row Level Security
- Firebase | Security Rules
- Google Cloud | IAM overview
- Vercel | Deployment Protection
- OWASP Top 10: A01 Broken Access Control