Use Cases (更新: 2026/6/6)

Claude CodeのFirebase開発でデータ流出を防ぐSecurity Rules設計

Claude CodeでFirebase(Auth/Firestore/Functions/Hosting)を実装。他人のデータが読めてしまう事故を防ぐSecurity Rulesとエミュレータ検証を実体験で解説。

Claude CodeのFirebase開発でデータ流出を防ぐSecurity Rules設計

「Firebaseでチケット機能、サクッと作っといて」

そう頼んで出てきたコードは、画面もログインも完璧に動いていました。デモも通った。でも公開直前にルールを読み返して、背筋が凍りました。allow read, write: if request.auth != null; ——ログインさえしていれば、他人の問い合わせ内容が全部読める状態だったんです。

Firebaseは「動く」までが速い。だからこそ、「安全に動く」との差で事故が起きます。今日は僕が踏んだ地雷を踏まないための、Claude CodeでのFirebase開発の進め方を書きます。

この記事の要点

  • Firebaseは認証・DB・Functions・Hostingをファイルとコマンドで管理できるので、Claude Codeとの相性がいい。差分が見やすい。
  • 一番の事故ポイントは Firestore Security Rulesrequest.auth != null だけで通すと、ログインユーザー全員が他人のデータを読める。
  • Security Rulesは「結果を絞るフィルター」ではない。クエリが返しうる全件がルール評価対象なので、画面側で絞る実装は拒否される。
  • Cloud FunctionsのAdmin SDKは Security Rulesを無視する。サーバー側で所有者検証を別途書かないと権限事故になる。
  • Emulator Suiteで「失敗するはずのケース」をテスト化しておくと、Claude Codeが後で権限を緩めても気づける。これが効く。
  • dev/stg/prodの取り違えは実装ミスより多い。firebase use を作業前に声に出すくらいでちょうどいい。

Claude Codeにどの単位で任せるか

最初に決めるのは「何を1タスクにするか」です。「Googleログインボタン作って」だと粒度が粗すぎて、Claude Codeはきれいなボタンを返してくる。でもルールもテストも付いてこない。結局あとで自分が権限を詰めることになります。

僕がたどり着いたのは、機能を縦に切るやり方です。「問い合わせチケットを作る」なら、フォーム・Firestore保存・Security Rules・ルールのテスト・Functions通知・Hosting設定までを同じタスクに含める。横(UIだけ、DBだけ)で切ると、つなぎ目の権限が抜け落ちます。

土台になる設計は別記事にまとめてあります。認証まわりの考え方はClaude Codeで認証実装を進める方法、テストとデプロイの自動化はClaude CodeでCI/CDを整える実践ガイドが参考になります。「Firebaseじゃなくて別のBaaSは?」と迷っているならClaude CodeとSupabase統合が比較材料になります。

全体像はこうです。ユーザーが認証を通り、UIからFirestoreに書き、ルールが番人をして、重い処理はFunctionsに寄せる。ローカルではEmulatorが全部の代役をします。

flowchart LR
  A["User"] --> B["Firebase Authentication"]
  B --> C["React or Astro UI"]
  C --> D["Cloud Firestore"]
  D --> E["Security Rules"]
  D --> F["Cloud Functions v2"]
  C --> G["Firebase Hosting"]
  H["Emulator Suite"] --> B
  H --> D
  H --> F

この記事のコードはコピペして検証しやすいよう Vite + React + TypeScript で書きます。Next.jsやAstroでも考え方は同じで、環境変数名(VITE_NEXT_PUBLIC_)とルーティングだけ読み替えてください。

まず一次情報を押さえる(公式リンク表)

Firebaseは仕様が地味に変わります。古いブログだけで判断すると、Security RulesやEmulatorの挙動でハマる。僕は実装に入る前に、Claude Codeへ「下の公式を一次情報として、古い前提を混ぜないで」と最初に伝えます。

領域公式リンクClaude Codeに任せる粒度
AuthenticationFirebase AuthenticationログインUI、プロフィール作成、Auth状態管理
FirestoreCloud Firestoreコレクション設計、読み書き関数、インデックス前提
Security RulesFirestore Security Rulesルール本体、失敗テスト、所有者チェック
Cloud FunctionsCloud Functions for Firebaseサーバー側検証、通知、集計、外部API連携
HostingFirebase HostingSPA配信、キャッシュ、プレビュー、本番デプロイ
EmulatorLocal Emulator Suiteローカル検証、ルールカバレッジ、CIテスト
料金Firebase pricing読み取り回数、Functions実行回数の見積もり
Claude CodeClaude Code docs作業分割、差分レビュー、テスト実行

僕が支援でよく使うプロジェクト構成はこの形です。Claude Codeにはまずこれを読ませて、「未作成なら追加、既存があればそのパターンに合わせて」と頼みます。いきなり新ファイルを乱立させないためのおまじないです。

.
├─ firebase.json
├─ firestore.rules
├─ firestore.indexes.json
├─ .firebaserc
├─ functions/
│  ├─ package.json
│  └─ src/index.ts
└─ src/
   ├─ lib/firebase.ts
   ├─ lib/tickets.ts
   └─ lib/useAuth.tsx

環境分離を最初に固定する(事故の半分はここ)

正直に言うと、Firebaseで一番ヒヤッとしたのはルールより環境の取り違えでした。devのつもりでprodのコンソールを開き、テストデータを本番に流しかけたことがあります。実装ミスじゃなくて、ただの勘違い。でもこれが一番多い。

なので、コードを書かせる前に dev / stg / prod を .firebaserc のエイリアスで固定します。プロジェクトIDは自分のFirebase Consoleで作ったものに置き換えてください。

{
  "projects": {
    "dev": "claudecodelab-firebase-dev",
    "stg": "claudecodelab-firebase-stg",
    "prod": "claudecodelab-firebase-prod"
  }
}

firebase.json ではFirestoreルール・インデックス・Functions・Hosting・Emulatorを1ファイルにまとめます。SPAなので rewrites で全部 index.html に流しています。

{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "functions": [
    {
      "source": "functions",
      "codebase": "default",
      "runtime": "nodejs20"
    }
  ],
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "headers": [
      {
        "source": "/assets/**",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "public, max-age=31536000, immutable"
          }
        ]
      }
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
  "emulators": {
    "auth": { "port": 9099 },
    "functions": { "port": 5001 },
    "firestore": { "port": 8080 },
    "hosting": { "port": 5000 },
    "ui": { "enabled": true, "port": 4000 },
    "singleProjectMode": true
  }
}

ローカルの公開設定は .env.local に置きます。ここで一つだけ覚えてほしいのは、FirebaseのWeb API keyは秘密鍵ではないということ。フロントに出てOKです。逆に、サービスアカウントJSONやAdmin SDKの認証情報は絶対にフロントへ置かないでください。混同すると本当に痛い目を見ます。

VITE_FIREBASE_API_KEY=replace-me
VITE_FIREBASE_AUTH_DOMAIN=claudecodelab-firebase-dev.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=claudecodelab-firebase-dev
VITE_FIREBASE_STORAGE_BUCKET=claudecodelab-firebase-dev.appspot.com
VITE_FIREBASE_APP_ID=replace-me
VITE_USE_FIREBASE_EMULATORS=true

認証とクライアント初期化

src/lib/firebase.ts で App・Auth・Firestore・Functionsを初期化し、ローカル開発のときだけEmulatorにつなぎます。Next.jsなら import.meta.envprocess.env.NEXT_PUBLIC_ に読み替えるだけです。

// src/lib/firebase.ts
import { initializeApp, getApp, getApps } from "firebase/app";
import {
  connectAuthEmulator,
  getAuth,
  GoogleAuthProvider,
} from "firebase/auth";
import { connectFirestoreEmulator, getFirestore } from "firebase/firestore";
import { connectFunctionsEmulator, getFunctions } from "firebase/functions";

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
};

// 二重初期化を防ぐ。HMRで何度も呼ばれても安全
const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);

export const auth = getAuth(app);
export const db = getFirestore(app);
export const functions = getFunctions(app, "asia-northeast1");
export const googleProvider = new GoogleAuthProvider();

const shouldUseEmulators =
  import.meta.env.DEV && import.meta.env.VITE_USE_FIREBASE_EMULATORS === "true";

const globalState = globalThis as typeof globalThis & {
  __firebaseEmulatorsConnected?: boolean;
};

// Emulator接続も一度だけ。連打すると例外になるのでフラグで守る
if (shouldUseEmulators && !globalState.__firebaseEmulatorsConnected) {
  connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings: true });
  connectFirestoreEmulator(db, "127.0.0.1", 8080);
  connectFunctionsEmulator(functions, "127.0.0.1", 5001);
  globalState.__firebaseEmulatorsConnected = true;
}

ログイン状態はReact Hookに切り出します。初回ログインで users/{uid} を作るところまで入れておくと、あとでFirestoreの所有者ルールとつなげやすくなります。

// src/lib/useAuth.tsx
import { useEffect, useState } from "react";
import {
  onAuthStateChanged,
  signInWithPopup,
  signOut,
  type User,
} from "firebase/auth";
import { doc, serverTimestamp, setDoc } from "firebase/firestore";
import { auth, db, googleProvider } from "./firebase";

type AuthState = {
  user: User | null;
  loading: boolean;
  signInWithGoogle: () => Promise<void>;
  logout: () => Promise<void>;
};

export function useAuth(): AuthState {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    return onAuthStateChanged(auth, (currentUser) => {
      setUser(currentUser);
      setLoading(false);
    });
  }, []);

  async function signInWithGoogle() {
    const result = await signInWithPopup(auth, googleProvider);
    // 初回ログインでプロフィールを作る。mergeなので再ログインでも壊れない
    await setDoc(
      doc(db, "users", result.user.uid),
      {
        uid: result.user.uid,
        email: result.user.email,
        displayName: result.user.displayName,
        photoURL: result.user.photoURL,
        updatedAt: serverTimestamp(),
      },
      { merge: true },
    );
  }

  return {
    user,
    loading,
    signInWithGoogle,
    logout: () => signOut(auth),
  };
}

Claude Codeに頼むときは、レビュー条件まで一緒に渡すと差分確認が一気に楽になります。

Firebase AuthのGoogleログインを実装してください。
- 対象はVite + React + TypeScript
- src/lib/firebase.tsの既存初期化を再利用
- 初回ログイン時にusers/{uid}をmergeで作成
- ログアウト関数も返す
- 本番のサービスアカウントや秘密鍵は触らない
- 実装後に型エラーとFirestore rulesへの影響を説明

Firestoreのデータ設計とCRUD

題材は「問い合わせチケット」。実際の現場で出てくるユースケースは、だいたい次の3つに集約されます。

  1. ログインユーザーが自分の問い合わせを作って進捗を見る 会員ポータル
  2. 管理者がCloud Functions経由でチケットを閉じる サポート運用
  3. 作成時に通知・集計をして対応漏れを減らす 業務ダッシュボード

クライアントからは「本人のチケットだけ」作成・一覧します。Firestoreの読み取りはドキュメント単位で課金されるので、必要以上に広いクエリをClaude Codeに書かせないのがコスト面でも効きます。

// src/lib/tickets.ts
import {
  addDoc,
  collection,
  getDocs,
  limit,
  orderBy,
  query,
  serverTimestamp,
  where,
  type Timestamp,
} from "firebase/firestore";
import { db } from "./firebase";

export type TicketStatus = "open" | "closed";

export type Ticket = {
  id: string;
  userId: string;
  title: string;
  body: string;
  status: TicketStatus;
  createdAt: Timestamp;
  updatedAt: Timestamp;
};

type CreateTicketInput = {
  userId: string;
  title: string;
  body: string;
};

export async function createTicket(input: CreateTicketInput): Promise<string> {
  const title = input.title.trim();
  const body = input.body.trim();

  // 入力検証はクライアント・ルール・Functionsの3層でやる。ここはその1層目
  if (title.length === 0 || title.length > 120) {
    throw new Error("Title must be between 1 and 120 characters.");
  }
  if (body.length === 0 || body.length > 4000) {
    throw new Error("Body must be between 1 and 4000 characters.");
  }

  const docRef = await addDoc(collection(db, "tickets"), {
    userId: input.userId,
    title,
    body,
    status: "open",
    createdAt: serverTimestamp(),
    updatedAt: serverTimestamp(),
  });

  return docRef.id;
}

export async function listMyTickets(userId: string): Promise<Ticket[]> {
  // 本人ぶんだけ・新しい順・20件まで。全件取得は絶対にしない
  const ticketsQuery = query(
    collection(db, "tickets"),
    where("userId", "==", userId),
    orderBy("createdAt", "desc"),
    limit(20),
  );

  const snapshot = await getDocs(ticketsQuery);

  return snapshot.docs.map((ticketDoc) => ({
    id: ticketDoc.id,
    ...(ticketDoc.data() as Omit<Ticket, "id">),
  }));
}

whereorderBy を組み合わせると、プロジェクトによっては複合インデックスを要求されます。Firebase Consoleのエラーに出るリンクから作ってもいいですが、僕は firestore.indexes.json に残してGit管理します。チームで再現できるからです。

{
  "indexes": [
    {
      "collectionGroup": "tickets",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "userId", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "DESCENDING" }
      ]
    }
  ],
  "fieldOverrides": []
}

Security Rulesはフィルターではない(ここが核心)

冒頭の事故の正体がこれです。多くの人が勘違いしているのですが、Firestore Security Rulesは「許可されないドキュメントを除外して返すフィルター」ではありません。公式にも明記されているとおり、クエリが返す可能性のある結果全体がルールで評価されます。

だから「全件取得して画面側で本人分だけ表示」という実装は、安全でないどころか ルールに拒否されて動きません。クエリ自体を本人範囲に絞る(さっきの where("userId", "==", userId))のが正解です。ルールはそれを前提に「本人かどうか」を判定します。

次の firestore.rules は、本人だけが自分のチケットを作成・閲覧でき、更新はタイトルと本文だけ、削除は全面禁止、管理者操作はクライアントに開けずFunctionsへ寄せる、という設計です。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    function isOwner(userId) {
      return signedIn() && request.auth.uid == userId;
    }

    function ticketFieldsAreValid() {
      return request.resource.data.keys().hasOnly([
        "userId", "title", "body", "status", "createdAt", "updatedAt"
      ])
      && request.resource.data.title is string
      && request.resource.data.title.size() > 0
      && request.resource.data.title.size() <= 120
      && request.resource.data.body is string
      && request.resource.data.body.size() > 0
      && request.resource.data.body.size() <= 4000;
    }

    match /users/{userId} {
      allow create, read, update: if isOwner(userId);
      allow delete: if false;
    }

    match /tickets/{ticketId} {
      allow create: if signedIn()
        && request.resource.data.userId == request.auth.uid
        && request.resource.data.status == "open"
        && ticketFieldsAreValid();

      allow read: if signedIn()
        && resource.data.userId == request.auth.uid;

      // 更新できるのはtitle/bodyだけ。statusはクライアントから変えさせない
      allow update: if signedIn()
        && resource.data.userId == request.auth.uid
        && request.resource.data.userId == resource.data.userId
        && request.resource.data.status == resource.data.status
        && request.resource.data.diff(resource.data).affectedKeys()
          .hasOnly(["title", "body", "updatedAt"])
        && ticketFieldsAreValid();

      allow delete: if false;
    }

    // 管理者用データはクライアントから一切触れない
    match /adminStats/{docId} {
      allow read, write: if false;
    }
  }
}

Claude Codeにルールをレビューさせるときの観点はこの4つです。request.auth.uidresource.data.userId を比べているか、keys().hasOnly で余計なフィールドを弾いているか、delete: if false を明示しているか、管理者操作をクライアントに開けていないか。「関数化して読みやすくして」までは任せていい。でも権限の境界線そのものは、最後に必ず自分の目で確認します。 ここを丸投げした日に事故ります。

Emulator Suiteで「失敗するはず」をテストにする

ルールを書いたら、Emulator Suiteで失敗ケースをテスト化します。これが一番のお守りです。なぜなら、Claude Codeに後で別の修正を頼んだとき、ついでに権限を緩めてしまっても、テストが赤くなって気づけるからです。

npm install -D vitest @firebase/rules-unit-testing firebase
firebase setup:emulators:firestore

テストは「本人は読める」「他人は読めない」「クライアントからstatusは変えられない」の3本を軸にします。通るテストより、落ちてほしいテストが落ちることのほうが大事です。

// tests/firestore.rules.test.ts
import { readFileSync } from "node:fs";
import {
  assertFails,
  assertSucceeds,
  initializeTestEnvironment,
  type RulesTestEnvironment,
} from "@firebase/rules-unit-testing";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { doc, getDoc, setDoc, updateDoc } from "firebase/firestore";

let testEnv: RulesTestEnvironment;

beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: "claudecodelab-firestore-rules",
    firestore: {
      // firebase.jsonと同じルールファイルを読むのが超重要
      rules: readFileSync("firestore.rules", "utf8"),
    },
  });
});

beforeEach(async () => {
  await testEnv.clearFirestore();
});

afterAll(async () => {
  await testEnv.cleanup();
});

describe("tickets security rules", () => {
  it("allows the owner to create and read a ticket", async () => {
    const aliceDb = testEnv.authenticatedContext("alice").firestore();
    const ticketRef = doc(aliceDb, "tickets/ticket-1");

    await assertSucceeds(
      setDoc(ticketRef, {
        userId: "alice",
        title: "請求書を再送してほしい",
        body: "4月分の請求書が見つかりません。",
        status: "open",
        createdAt: new Date(),
        updatedAt: new Date(),
      }),
    );

    await assertSucceeds(getDoc(ticketRef));
  });

  it("blocks another user from reading the ticket", async () => {
    // 仕込みはルール無効で。検証だけ本物のルールに通す
    await testEnv.withSecurityRulesDisabled(async (context) => {
      await setDoc(doc(context.firestore(), "tickets/ticket-2"), {
        userId: "alice",
        title: "契約内容の確認",
        body: "現在のプランを確認したいです。",
        status: "open",
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    });

    const bobDb = testEnv.authenticatedContext("bob").firestore();
    await assertFails(getDoc(doc(bobDb, "tickets/ticket-2")));
  });

  it("blocks status changes from the web client", async () => {
    await testEnv.withSecurityRulesDisabled(async (context) => {
      await setDoc(doc(context.firestore(), "tickets/ticket-3"), {
        userId: "alice",
        title: "ログインできない",
        body: "Googleログインでエラーになります。",
        status: "open",
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    });

    const aliceDb = testEnv.authenticatedContext("alice").firestore();
    // 本人でもstatusは変えられない(Functions経由が必須)
    await assertFails(
      updateDoc(doc(aliceDb, "tickets/ticket-3"), {
        status: "closed",
        updatedAt: new Date(),
      }),
    );
  });
});

package.json にはローカルとCIで同じコマンドを使えるようスクリプトを入れます。「手元だけ通る」を減らすためです。

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "firebase:use:dev": "firebase use dev",
    "firebase:emulators": "firebase emulators:start --only auth,firestore,functions,hosting",
    "test:rules": "firebase emulators:exec --only firestore \"vitest run tests/firestore.rules.test.ts\"",
    "deploy:stg": "firebase use stg && npm run build && firebase deploy --only hosting,firestore:rules,firestore:indexes,functions",
    "deploy:prod": "firebase use prod && npm run build && firebase deploy --only hosting,firestore:rules,firestore:indexes,functions"
  }
}

Cloud Functionsに寄せる処理と、Admin SDKの罠

クライアントに任せると改ざんされる処理、外部APIキーを使う処理、集計や通知は、Cloud Functionsに寄せます。ここでは「チケットを閉じるCallable Function」と「作成時に管理者通知を作るFirestoreトリガー」を実装します。

// functions/src/index.ts
import { initializeApp } from "firebase-admin/app";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { HttpsError, onCall } from "firebase-functions/v2/https";

initializeApp();

const db = getFirestore();

export const closeTicket = onCall(
  { region: "asia-northeast1" },
  async (request) => {
    // Admin SDKはルールを無視する。だから所有者検証を自分で書く
    if (!request.auth) {
      throw new HttpsError("unauthenticated", "Sign-in is required.");
    }

    const ticketId = request.data?.ticketId;
    if (typeof ticketId !== "string" || ticketId.length > 100) {
      throw new HttpsError("invalid-argument", "ticketId is invalid.");
    }

    const ticketRef = db.doc(`tickets/${ticketId}`);
    const ticketSnap = await ticketRef.get();
    if (!ticketSnap.exists) {
      throw new HttpsError("not-found", "Ticket was not found.");
    }

    const ticket = ticketSnap.data();
    if (ticket?.userId !== request.auth.uid) {
      throw new HttpsError("permission-denied", "You cannot close this ticket.");
    }

    await ticketRef.update({
      status: "closed",
      closedAt: FieldValue.serverTimestamp(),
      updatedAt: FieldValue.serverTimestamp(),
    });

    return { ok: true };
  },
);

export const notifyTicketCreated = onDocumentCreated(
  { document: "tickets/{ticketId}", region: "asia-northeast1" },
  async (event) => {
    const ticket = event.data?.data();
    if (!ticket) return;

    await db.collection("adminNotifications").add({
      type: "ticket_created",
      ticketId: event.params.ticketId,
      title: ticket.title,
      userId: ticket.userId,
      createdAt: FieldValue.serverTimestamp(),
      read: false,
    });
  },
);

ここで一番大事な落とし穴を繰り返します。Admin SDKはFirestore Security Rulesをバイパスします。 つまりクライアント側のルールがどれだけ堅くても、Function内で ticket?.userId !== request.auth.uid のような検証を省いた瞬間、サーバー側で権限事故が起きる。Claude Codeには「Admin SDKはルールを通らない前提で、Function内に入力検証と所有者検証を必ず入れて」と毎回明示します。

Hostingとデプロイ前チェック

Hostingは公開が簡単すぎるのが逆に怖い。だからレビュー手順を固定して、プレビュー → ステージング → 本番、と段を踏みます。

firebase login
firebase use dev
npm run build
firebase emulators:start --only auth,firestore,functions,hosting
firebase hosting:channel:deploy preview-firebase-ticket
firebase use stg
firebase deploy --only hosting,firestore:rules,firestore:indexes,functions

Claude Codeに見させるのは、firebase use の対象、.firebaserc のプロジェクトID、firebase.json のpublicディレクトリ、SPAのrewrites、静的アセットのキャッシュ、Functionsのリージョン、RulesとIndexesの同時デプロイ。本番デプロイだけは、コマンド案をClaude Codeに出させても、対象プロジェクトを自分で確認してから実行します。 自動実行は絶対にさせません。

僕が踏んだ失敗5つと回避策

#やらかし何が起きたか回避策
1allow read, write: if request.auth != null;ログインユーザー全員が他人のデータを読めた本人所有を resource.data.userId == request.auth.uid で必ず縛る
2全件取得→画面側で絞るコストもセキュリティも悪化、ルールにも拒否されるクエリ自体を where("userId","==",uid) で本人範囲に
3FunctionsでAdmin SDKを使い所有者検証を省略サーバー側で権限事故Callableで request.auth・入力型・所有者を毎回検証
4Emulatorにルールを読み込ませていない開いた状態に見えてテストが意味なしfirebase.json とテストの firestore.rules を一致させる
5dev/prodを同じタブで切り替え続けたテストデータを本番に流しかけた作業前に firebase use.env.local・ConsoleのURLを声に出す

特に1と5は、技術力と関係なく、忙しい日に誰でも踏みます。仕組みで止めるしかありません。

コストとセキュリティのレビュー観点

料金は変わるので、最新額は必ずFirebase pricingで確認します。記事やAIの回答に古い無料枠が残っていることがあるので、金額を断定して設計しないのが安全です。

  • コスト: Firestoreの読み取り回数、一覧画面の limit、リアルタイム購読の解除忘れ、Functionsのリージョンとタイムアウト、ログ量、Hostingのキャッシュ。一覧で毎回全件読み直すと、コストも体感速度も悪化します。
  • セキュリティ: サービスアカウントJSONをリポジトリに置かない、FIREBASE_TOKEN のようなCI秘密情報をClaude Codeのプロンプトに貼らない、本番のOwner権限を普段使いしない、App Checkなど不正利用対策を検討する。

Claude Codeの作業権限は「必要なファイルだけ編集、デプロイはしない、差分とテスト結果を報告」に絞るのが現実的です。

Claude Codeへの依頼テンプレート

最後に、僕が実際に使っている依頼文を置いておきます。作業対象・禁止事項・検証方法・レビュー観点が全部入っているのがポイントです。

Firebaseの問い合わせチケット機能を実装してください。

対象:
- Vite + React + TypeScript
- Firebase Auth, Firestore, Cloud Functions v2, Hosting
- 対象ファイルはsrc/lib/firebase.ts, src/lib/useAuth.tsx, src/lib/tickets.ts, firestore.rules, functions/src/index.ts, firebase.jsonのみ

要件:
- Googleログイン済みユーザーだけがticketsを作成できる
- 本人だけが自分のticketsを読める
- status変更はWebクライアントから禁止し、Callable Functionで行う
- Emulator SuiteでFirestore Rulesの成功/失敗テストを追加する
- dev/stg/prodのFirebaseプロジェクトを混同しない

禁止:
- サービスアカウントJSONを作成または表示しない
- 本番デプロイを実行しない
- allow read, write: if trueを使わない

完了報告:
- 変更ファイル
- 実行したテスト
- ルール上の権限境界
- 残る手動確認

この粒度なら、Claude Codeが実装担当、自分はプロダクト判断と権限判断に集中できます。役割が分かれると、レビューがぐっと速くなります。

よくある質問

Q. FirebaseのWeb API keyをフロントに出して大丈夫? A. 大丈夫です。Web API keyは秘密鍵ではなく、プロジェクトを識別するための公開値です。守るべきはSecurity RulesとApp Checkで、鍵を隠すことではありません。一方、サービスアカウントJSONとAdmin SDKの認証情報は絶対にフロントへ置かないでください。

Q. Security Rulesを書けば、もうFunctions側の検証はいらない? A. いりません、とは言えません。Admin SDKはSecurity Rulesをバイパスするので、Functions内では所有者検証と入力検証を別途書きます。クライアント側ルールとサーバー側検証は別の権限境界だと考えてください。

Q. 「全件取得して画面で絞る」だとなぜ動かない? A. Security Rulesはフィルターではなく、クエリが返しうる全件を評価するからです。本人以外のドキュメントが結果に含まれうる時点でルールに拒否されます。クエリ自体を where("userId","==",uid) で本人範囲に絞るのが正解です。

Q. 複合インデックスのエラーが出たらどうする? A. Firebase Consoleのエラーに作成リンクが出るのでそこから作れます。ただしチームで再現できるよう firestore.indexes.json に残してGit管理し、デプロイ時に一緒に反映するのがおすすめです。

Q. Claude Codeに本番デプロイまで任せていい? A. 僕はおすすめしません。コマンド案を出させるのは便利ですが、実行は firebase use の対象を自分の目で確認してから手で行います。dev/prodの取り違えは技術力と無関係に起きる事故なので、最後の引き金だけは人間が引きます。

実際に試した結果

冒頭の「他人のデータが全部読める」状態に気づいて以来、僕はFirebaseで「動いた」を信用するのをやめました。代わりに見るのは、落ちてほしいテストが、ちゃんと落ちているかです。

Emulatorに「他人は読めない」「クライアントからstatusは変えられない」の2本を入れただけで、その後Claude Codeに別の修正を頼んでも、権限がうっかり緩むことがなくなりました。firebase use を作業前に声に出す習慣をつけてからは、環境の取り違えもゼロです。賢いAIに任せきるより、転んでもデータが漏れない足場を先に作る。遠回りに見えて、これが一番速くて安全だ、というのが今の実感です。

まずはEmulatorで縦切り機能を1つ作り、失敗テストを入れ、Functionsの所有者検証をレビューし、プレビュー経由で公開する。ここまでやってから本番に進めると、Firebase開発の速度を上げながら事故を減らせます。手を動かす土台がほしいなら実践的な教材一覧も覗いてみてください。チームのSecurity Rulesレビューや研修が必要なら研修・相談から、いまの構成を添えて声をかけてください。

#Claude Code #Firebase #Firestore #Security Rules #Cloud Functions
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。