この記事でわかること
VITE_環境変数はブラウザバンドルに平文で展開されるという事実と、その危険性- Cloudflare Pages Functionsをサーバーレイヤーとして挟む移行パターン
- AIが生成したコードでもセキュリティ上の設計ミスは起きるという教訓
背景
コーヒー抽出記録アプリ「MyCupLog」を Vite + React + Cloudflare Pages + Turso(libSQL)で個人開発しています。
認証には Better Auth を使用しており、フロントエンドのコードはほぼ Claude Code と Codex CLI に任せています。
開発がある程度進んだ段階でコードを見直したとき、ひとつ重大なことに気づきました。
DBのアクセストークンが .env ファイルの VITE_TURSO_AUTH_TOKEN というキー名で管理されており、そのまま本番ビルドに含まれていたのです。
ハマったポイント
何が起きていたか
Viteには「VITE_ プレフィックスの付いた環境変数だけをクライアントサイドに公開する」という仕様があります。
これはフロントエンド開発者が意図して公開する変数を制御するための仕組みですが、裏を返せば VITE_ を付けた変数は本番ビルドのJavaScriptバンドルに平文で展開されるということでもあります。
// ❌ 問題のあったコード(src/lib/db.ts)
const url = import.meta.env.VITE_TURSO_DATABASE_URL
const authToken = import.meta.env.VITE_TURSO_AUTH_TOKEN
export const db = createClient({ url, authToken })ブラウザの DevTools → Sources → バンドルファイル を開けば、VITE_TURSO_AUTH_TOKEN の値がそのまま文字列として読み取れる状態でした。
このトークンがあれば、誰でも全ユーザーのDBを直接操作できます。
なぜこうなったか
初期実装時、「フロントから直接Tursoに接続できる」という手軽さを優先したことが原因です。
認証(Better Auth)はサーバー側で動かしており、TURSO_AUTH_TOKEN(VITE_ なし)で正しく管理できていました。
しかし DB クエリ部分は「まず動かす」を優先してフロント直接接続のまま進めてしまいました。
AIに実装を依頼したとき、AIは「動くコード」を書きますが、「このアーキテクチャがセキュリティ上問題ないか」を自発的に指摘するとは限りません。
設計レベルの判断は人間が行う必要があります。
解決策:Cloudflare Pages Functions API経由に移行する
DBアクセスをすべて Cloudflare Pages Functions(functions/ ディレクトリ)のAPIエンドポイントに移行しました。
// ✅ 修正後:functions/api/materials/index.ts
import { createClient } from '@libsql/client'
import { createAuth } from '../../../src/lib/auth.server'
export const onRequestGet: PagesFunction<Env> = async (ctx) => {
// サーバー側でセッションからuserIdを取得(クライアントの値を信頼しない)
const auth = createAuth(ctx.env)
const session = await auth.api.getSession({ headers: ctx.request.headers })
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const db = createClient({
url: ctx.env.TURSO_DATABASE_URL, // VITE_ なし — ブラウザに公開されない
authToken: ctx.env.TURSO_AUTH_TOKEN, // VITE_ なし — ブラウザに公開されない
})
const result = await db.execute({
sql: 'SELECT id, name, type, origin FROM materials WHERE user_id = ?',
args: [session.user.id],
})
return Response.json({ materials: result.rows })
}フロントエンドは fetch('/api/materials') を呼ぶだけになり、DBトークンはサーバー側のみで管理されます。
再発防止策
.env のキー命名規則を明示してプロジェクトルールに追加しました。
<!-- .claude/rules/database.md に追記 -->
## 環境変数の命名規則
- `VITE_` プレフィックスはブラウザに公開される。DBトークン・秘密鍵には絶対に付けない
- フロントが使う公開情報(APIのベースURLなど)のみ VITE_ を使う
- DBアクセスは必ず Cloudflare Pages Functions 経由にすること実装
移行は4フェーズに分けて実施しました(35ファイル・約2,300行の変更)。
- Phase A: Materials API を Functions に移行、フロントを
/api/materialsに向け替え - Phase B: Brew Logs API を移行
- Phase C: Best Recipes・Stats API を移行
- Phase D:
src/lib/db.tsからVITE_TURSO_*の参照をすべて削除、.env.exampleを更新
移行後、Codex CLI にセキュリティレビューを依頼したところ「materialId の所有者チェックがサーバー側で不足している」という追加指摘を受け、その修正も同じPRに含めました。
AIレビューが人間の見落としを補った好例です。
Claude Codeはどこまで使ったか
任せた部分: 各 API エンドポイントファイルの実装・フロントのfetch呼び出しへの書き換え・セキュリティレビュー(Codex CLI)
自分でやった部分: 「VITE_ トークンがバンドルに含まれる」という問題の発見。移行方針の決定(Functions APIを挟む設計)。
修正が必要だった部分: Codex が指摘した materialId 所有者チェックの追加。AIは言われた実装は正確にやるが、言われていないセキュリティ要件を先回りして追加することは少ない。
まとめ
VITE_環境変数はビルド成果物に平文展開される — DBトークン・秘密鍵には絶対に付けない- 「動くコード」と「安全なコード」は別物 — AIが生成したコードでも設計レベルのセキュリティ確認は必要
- Cloudflare Pages Functions はサーバーレイヤーとして機能する — フロント直接接続をやめてAPIに集約することでトークンを守れる