フロントエンドとバックエンドの境界線:supabase-admin.ts を分離すべき理由
Next.js (App Router)やAstroなどのモダンなWebフレームワークを使っていると、「サーバーサイドで動くコード」と「クライアントサイドで動くコード」を同じプロジェクト内で扱うことになります。ここで油断すると、「サーバー専用の秘密鍵」がクライアント側のJavaScriptバンドルに漏洩するという事故が起きます。
本記事では、Supabase、Firebase、Stripeなどのサービスを使ったプロジェクトで実際に有効な「物理的なファイル分割」による防御策を、ビルドツールの挙動と合わせて解説します。
秘密鍵漏洩の実態:CVE事例と影響範囲
実際に起きた事故の例
| 事例 | 漏洩した鍵の種類 | 影響 | 発見方法 |
|---|---|---|---|
| GitHubリポジトリ公開事故 | AWS Secret Access Key | S3バケット全削除、$10,000の不正請求 | GitHub Secret Scanning |
| クライアントバンドル混入 | Supabase Service Role Key | 全ユーザーデータの読み取り・削除 | Chrome DevTools Sources検索 |
| 環境変数の誤設定 | Stripe Secret Key (sk_live_xxx) | 不正決済の実行、顧客情報漏洩 | 静的解析ツール(GitGuardian) |
| Dockerイメージへの埋め込み | Database接続文字列 | 本番DBへの直接アクセス | Docker image inspect |
❌ よくある失敗パターン:utils.tsやsupabase.tsへの集約
プロジェクト初期には、Supabase関連の処理を1つのファイルにまとめたくなります。
脆弱なコード例
// ❌ BAD: src/lib/supabase.ts(クライアント/サーバーコードが混在)
import { createClient } from '@supabase/supabase-js';
// 環境変数取得(クライアント用とサーバー用が混在)
const url = process.env.PUBLIC_SUPABASE_URL!;
const anonKey = process.env.PUBLIC_SUPABASE_ANON_KEY!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // ⚠️ 危険!
// 通常クライアント(ブラウザで使用)
export const supabase = createClient(url, anonKey);
// 管理者クライアント(サーバー専用のつもり...)
export function getAdminClient() {
return createClient(url, serviceKey);
}
この実装の3つの脆弱性
脆弱性1: バンドラによる依存関係の全解析
// クライアントコンポーネント(ブラウザで動作)
import { supabase } from '@/lib/supabase'; // ← これだけのつもりが...
// バンドラ(Webpack/Vite/Turbopack)の挙動:
// 1. supabase.ts 全体を解析
// 2. トップレベルで宣言された serviceKey もスコープに含まれる
// 3. Tree Shakingが不完全な場合、serviceKey がバンドルに残る
実際のビルド結果(Vite 5.0の例):
// dist/assets/index-abc123.js(圧縮前)
const a="https://xxx.supabase.co";
const b="eyJhbGc..."; // anon key
const c="eyJhbGc..."; // ⚠️ service role key(漏洩!)
const s=createClient(a,b);
function getAdminClient(){return createClient(a,c)}
脆弱性2: TypeScriptのトランスパイル後の展開
TypeScriptからJavaScriptへの変換時に、すべての変数が同一スコープに展開されます:
// トランスパイル後(tsc出力)
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const supabase_js_1 = require("@supabase/supabase-js");
const url = process.env.PUBLIC_SUPABASE_URL;
const anonKey = process.env.PUBLIC_SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; // ← ここに残る
exports.supabase = (0, supabase_js_1.createClient)(url, anonKey);
function getAdminClient() {
return (0, supabase_js_1.createClient)(url, serviceKey);
}
exports.getAdminClient = getAdminClient;
脆弱性3: 開発者の誤インポート
開発が進むと以下のような事故が起こります:
// pages/admin/users.tsx(クライアントコンポーネント)
import { supabase, getAdminClient } from '@/lib/supabase';
// ^^^^^^^^^^^^^ ← 誤ってインポート
// ESLintやTypeScriptでは検出できない
// ビルドエラーにもならない(実行時エラーになる場合もあるが、既に漏洩済み)
✅ 正しい実装パターン:物理的なファイル分割
「注意してインポートする」という人的運用は必ず破綻します。最も確実な対策は、「サーバー専用のファイルを作り、クライアントからはインポート不可能にする」 ことです。
ディレクトリ構成
src/
├── lib/
│ ├── supabase-client.ts # ✅ ブラウザ用(PUBLIC_キーのみ)
│ └── supabase-admin.server.ts # ✅ サーバー用(.server拡張子)
├── app/ # Next.js App Router
│ ├── api/
│ │ └── users/
│ │ └── route.ts # ← supabase-admin.server.ts を使用
│ └── page.tsx # ← supabase-client.ts のみ使用可能
└── middleware.ts # ← supabase-admin.server.ts を使用
1. supabase-client.ts(ブラウザ用)
// ✅ GOOD: src/lib/supabase-client.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from '@/types/database.types';
// PUBLIC_ プレフィックス付き環境変数のみ使用
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// 型安全なクライアント生成
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
},
});
// クライアント側での使用例
// import { supabase } from '@/lib/supabase-client';
// const { data } = await supabase.from('posts').select('*');
2. supabase-admin.server.ts(サーバー用)
// ✅ GOOD: src/lib/supabase-admin.server.ts
import { createClient } from '@supabase/supabase-js';
import type { Database } from '@/types/database.types';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// サーバー専用キーが存在しない場合はエラー(フェイルセーフ)
if (!supabaseServiceKey) {
throw new Error(
'SUPABASE_SERVICE_ROLE_KEY is missing. This file must only run on the server.'
);
}
// 管理者権限クライアント(RLSバイパス可能)
export const supabaseAdmin = createClient<Database>(
supabaseUrl,
supabaseServiceKey,
{
auth: {
persistSession: false, // サーバー側ではセッション不要
autoRefreshToken: false,
},
}
);
// サーバー側での使用例
// import { supabaseAdmin } from '@/lib/supabase-admin.server';
// const { data } = await supabaseAdmin.from('users').select('email');
フレームワーク別の防御メカニズム
Next.js 14 App Router
Next.jsは.server.ts拡張子を持つファイルを自動的にサーバー専用として扱います。
// next.config.js
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
// クライアントバンドルから .server.ts を除外
config.resolve.alias = {
...config.resolve.alias,
'@/lib/supabase-admin.server': false,
};
}
return config;
},
};
Astro 4.0+
Astroは.server.tsファイルをクライアントバンドルから自動除外します。
// astro.config.mjs
export default defineConfig({
vite: {
resolve: {
alias: {
// クライアント側で .server.ts をインポートすると空オブジェクトを返す
'@/lib/supabase-admin.server': new URL(
'./src/lib/supabase-admin.server.ts',
import.meta.url
).pathname,
},
},
ssr: {
noExternal: ['@supabase/supabase-js'], // サーバー側でのみバンドル
},
},
});
SvelteKit 2.0
SvelteKitは$lib/server/*ディレクトリ配下を自動的にサーバー専用とします。
src/
└── lib/
├── supabase-client.ts # クライアント/サーバー両用
└── server/
└── supabase-admin.ts # ← サーバー専用(自動保護)
// src/routes/api/users/+server.ts
import { supabaseAdmin } from '$lib/server/supabase-admin'; // ✅ OK
// src/routes/+page.svelte
import { supabaseAdmin } from '$lib/server/supabase-admin'; // ❌ ビルドエラー
物理的ファイル分割のメリット
メリット1: ビルド時エラー検出
クライアントコンポーネントから誤って.server.tsをインポートすると:
Error: Cannot import server-only module in client component
→ src/lib/supabase-admin.server.ts
Imported by: src/app/page.tsx
メリット2: 実行時の安全性
仮にビルドチェックをすり抜けても、ブラウザでの実行時にエラーが発生します:
// ブラウザコンソール
Uncaught Error: SUPABASE_SERVICE_ROLE_KEY is missing.
This file must only run on the server.
at supabase-admin.server.ts:8
メリット3: コードレビューの容易性
ファイル名に.server.tsがあれば、レビュアーは即座に「このファイルはサーバー専用」と判断できます。
// Pull Requestの差分
+ import { supabaseAdmin } from '@/lib/supabase-admin.server';
- import { getAdminClient } from '@/lib/supabase';
// レビュアーのコメント:
// "✅ .server.ts からのインポートなので安全"
検証チェックリスト
以下のチェックリストを使用して、実装の安全性を確認してください。
必須項目
推奨項目
実践的な検証方法
1. ビルド後のバンドルファイル検査
# Next.js App Routerの場合
npm run build
grep -r "service.*role.*key" .next/static/chunks/
# Astroの場合
npm run build
grep -r "SUPABASE_SERVICE_ROLE_KEY" dist/_astro/
# 検出された場合 → 漏洩!即座に修正
2. Chrome DevToolsでの手動確認
- 本番サイトにアクセス
- F12でDevToolsを開く
- Sourcesタブ → Ctrl+Shift+F で全ファイル検索
- 検索ワード:
service_role、sk_live(Stripe)、secret_key
3. 自動検証スクリプト(package.jsonに追加)
{
"scripts": {
"build": "next build",
"verify:secrets": "node scripts/verify-no-secrets.mjs"
}
}
// scripts/verify-no-secrets.mjs
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
const FORBIDDEN_PATTERNS = [
/SUPABASE_SERVICE_ROLE_KEY/,
/sk_live_/,
/sk_test_.*secret/,
/-----BEGIN PRIVATE KEY-----/,
];
const distDir = '.next/static/chunks';
const files = readdirSync(distDir, { recursive: true });
for (const file of files) {
if (!file.endsWith('.js')) continue;
const content = readFileSync(join(distDir, file), 'utf-8');
for (const pattern of FORBIDDEN_PATTERNS) {
if (pattern.test(content)) {
console.error(`❌ Secret detected in ${file}`);
process.exit(1);
}
}
}
console.log('✅ No secrets found in client bundle');
ESLint設定による自動防御
eslint-plugin-server-only(推奨)
npm install -D eslint-plugin-server-only
// .eslintrc.js
module.exports = {
plugins: ['server-only'],
rules: {
'server-only/no-client-import': [
'error',
{
serverOnlyFiles: ['**/*.server.ts', '**/server/**'],
},
],
},
};
Next.js専用: server-onlyパッケージ
npm install server-only
// src/lib/supabase-admin.server.ts
import 'server-only'; // ← この行を追加
export const supabaseAdmin = createClient(/* ... */);
クライアント側でインポートすると即座にビルドエラーが発生します:
Error: This module cannot be imported from a Client Component module.
It should only be used from a Server Component.
まとめ:セキュリティは「仕組み」で防ぐ
3つの防御原則
| 原則 | 実装方法 | 効果 |
|---|---|---|
| 物理的分離 | .server.ts拡張子、/serverディレクトリ | バンドラによる自動除外 |
| 実行時検証 | 環境変数の存在チェック(if (!key) throw Error) | ブラウザでの実行を即座に検出 |
| 静的解析 | ESLint、TypeScript、CIツール | 開発段階での誤インポート検出 |
Trust Boundary(信頼境界)の可視化
┌─────────────────────────────────────────────────┐
│ クライアント(信頼できない領域) │
│ - supabase-client.ts のみインポート可能 │
│ - PUBLIC_ プレフィックス付き環境変数のみアクセス │
└─────────────────────────────────────────────────┘
↓ HTTPS
┌─────────────────────────────────────────────────┐
│ サーバー(信頼境界) │
│ ✅ supabase-admin.server.ts を使用可能 │
│ ✅ SERVICE_ROLE_KEY などの秘密鍵にアクセス可能 │
│ ✅ RLSバイパス、管理者操作が可能 │
└─────────────────────────────────────────────────┘
「サーバー専用コード」と「共有コード」は、同じファイルに混ぜない。 物理的にファイルを分けることで、バンドラのミスやインポートミスによる漏洩をシステム的に防ぎます。
セキュリティは「気をつける」ではなく「仕組みで防ぐ」のが基本です。