フロントエンドとバックエンドの境界線:supabase-admin.ts を分離すべき理由

Next.js (App Router)やAstroなどのモダンなWebフレームワークを使っていると、「サーバーサイドで動くコード」と「クライアントサイドで動くコード」を同じプロジェクト内で扱うことになります。ここで油断すると、「サーバー専用の秘密鍵」がクライアント側のJavaScriptバンドルに漏洩するという事故が起きます。

本記事では、Supabase、Firebase、Stripeなどのサービスを使ったプロジェクトで実際に有効な「物理的なファイル分割」による防御策を、ビルドツールの挙動と合わせて解説します。


秘密鍵漏洩の実態:CVE事例と影響範囲

実際に起きた事故の例

事例漏洩した鍵の種類影響発見方法
GitHubリポジトリ公開事故AWS Secret Access KeyS3バケット全削除、$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つのファイルにまとめたくなります。

脆弱なコード例

typescript
// ❌ 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: バンドラによる依存関係の全解析

typescript
// クライアントコンポーネント(ブラウザで動作)
import { supabase } from '@/lib/supabase'; // ← これだけのつもりが...

// バンドラ(Webpack/Vite/Turbopack)の挙動:
// 1. supabase.ts 全体を解析
// 2. トップレベルで宣言された serviceKey もスコープに含まれる
// 3. Tree Shakingが不完全な場合、serviceKey がバンドルに残る

実際のビルド結果(Vite 5.0の例):

javascript
// 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への変換時に、すべての変数が同一スコープに展開されます:

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: 開発者の誤インポート

開発が進むと以下のような事故が起こります:

typescript
// pages/admin/users.tsx(クライアントコンポーネント)
import { supabase, getAdminClient } from '@/lib/supabase';
//                  ^^^^^^^^^^^^^ ← 誤ってインポート

// ESLintやTypeScriptでは検出できない
// ビルドエラーにもならない(実行時エラーになる場合もあるが、既に漏洩済み)

✅ 正しい実装パターン:物理的なファイル分割

「注意してインポートする」という人的運用は必ず破綻します。最も確実な対策は、「サーバー専用のファイルを作り、クライアントからはインポート不可能にする」 ことです。

ディレクトリ構成

plain text
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(ブラウザ用)

typescript
// ✅ 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(サーバー用)

typescript
// ✅ 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拡張子を持つファイルを自動的にサーバー専用として扱います。

typescript
// 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ファイルをクライアントバンドルから自動除外します。

typescript
// 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/*ディレクトリ配下を自動的にサーバー専用とします。

plain text
src/
└── lib/
    ├── supabase-client.ts        # クライアント/サーバー両用
    └── server/
        └── supabase-admin.ts     # ← サーバー専用(自動保護)
typescript
// 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をインポートすると:

bash
Error: Cannot import server-only module in client component
  → src/lib/supabase-admin.server.ts
  Imported by: src/app/page.tsx

メリット2: 実行時の安全性

仮にビルドチェックをすり抜けても、ブラウザでの実行時にエラーが発生します:

javascript
// ブラウザコンソール
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があれば、レビュアーは即座に「このファイルはサーバー専用」と判断できます。

diff
// Pull Requestの差分
+ import { supabaseAdmin } from '@/lib/supabase-admin.server';
- import { getAdminClient } from '@/lib/supabase';

// レビュアーのコメント:
// "✅ .server.ts からのインポートなので安全"

検証チェックリスト

以下のチェックリストを使用して、実装の安全性を確認してください。

必須項目

推奨項目


実践的な検証方法

1. ビルド後のバンドルファイル検査

bash
# 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での手動確認

  1. 本番サイトにアクセス
  2. F12でDevToolsを開く
  3. Sourcesタブ → Ctrl+Shift+F で全ファイル検索
  4. 検索ワード: service_rolesk_live(Stripe)、secret_key

3. 自動検証スクリプト(package.jsonに追加)

json
{
  "scripts": {
    "build": "next build",
    "verify:secrets": "node scripts/verify-no-secrets.mjs"
  }
}
javascript
// 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(推奨)

bash
npm install -D eslint-plugin-server-only
javascript
// .eslintrc.js
module.exports = {
  plugins: ['server-only'],
  rules: {
    'server-only/no-client-import': [
      'error',
      {
        serverOnlyFiles: ['**/*.server.ts', '**/server/**'],
      },
    ],
  },
};

Next.js専用: server-onlyパッケージ

bash
npm install server-only
typescript
// src/lib/supabase-admin.server.ts
import 'server-only'; // ← この行を追加

export const supabaseAdmin = createClient(/* ... */);

クライアント側でインポートすると即座にビルドエラーが発生します:

plain text
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(信頼境界)の可視化

plain text
┌─────────────────────────────────────────────────┐
│ クライアント(信頼できない領域)                   │
│ - supabase-client.ts のみインポート可能            │
│ - PUBLIC_ プレフィックス付き環境変数のみアクセス     │
└─────────────────────────────────────────────────┘
                    ↓ HTTPS
┌─────────────────────────────────────────────────┐
│ サーバー(信頼境界)                              │
│ ✅ supabase-admin.server.ts を使用可能            │
│ ✅ SERVICE_ROLE_KEY などの秘密鍵にアクセス可能      │
│ ✅ RLSバイパス、管理者操作が可能                   │
└─────────────────────────────────────────────────┘

「サーバー専用コード」と「共有コード」は、同じファイルに混ぜない。 物理的にファイルを分けることで、バンドラのミスやインポートミスによる漏洩をシステム的に防ぎます。

セキュリティは「気をつける」ではなく「仕組みで防ぐ」のが基本です。


参考資料