Next.js + Firebase security — rules-first architecture

Firebase inverts the usual security model. Instead of per-endpoint authorization in your application code, you write declarative rules in `firestore.rules` and `storage.rules` that the Firebase backend enforces. This is powerful when it works and catastrophic when it doesn't — one misconfigured rule can expose an entire collection to the public internet. The three failure modes we see repeatedly: default-allow rules (`allow read, write: if true`) left over from a tutorial, Admin SDK usage leaking into a client-reachable path (Admin SDK bypasses every rule), and callable functions that skip the `context.auth` check. The last one is subtle — Firebase callable functions look authenticated because they require a Firebase SDK call, but the SDK doesn't enforce auth; your function has to. Firebase App Check is the under-used defense. It verifies requests are coming from your legitimate app (not replays or scripts) before they hit rules or functions. Turn it on in production.

What breaks on this stack

Default-allow Firestore rules

`match /{document=**} { allow read, write: if true }` at the root of firestore.rules means every document in every collection is reachable by any anonymous client with your public web config. This ships in many tutorials and sample apps and never gets fixed.

Admin SDK in client-reachable path

`firebase-admin` bypasses every Security Rule by design. If it runs anywhere a browser can reach — a Next.js API route without auth, a misconfigured server component that's actually client-side — every rule you wrote is bypassed.

Callable function without context.auth

Firebase callable functions are authenticated only if you check. `if (!context.auth) throw new HttpsError('unauthenticated', 'login required')` — add it as the first line of every function.

Over-broad rules using request.auth.uid

`allow read: if request.auth != null` lets any authenticated user read any document. Rules must scope by ownership: `allow read: if request.auth.uid == resource.data.ownerId`.

No App Check

Without App Check, attackers can call your Firebase project from any client — including scripts running against your public config. App Check verifies the request origin using reCAPTCHA, Play Integrity, or DeviceCheck.

Pre-ship checklist

  • Every Firestore rule scoped by auth.uid + tenant where applicable
  • No `allow read, write: if true` anywhere in firestore.rules or storage.rules
  • Admin SDK import restricted to server-only paths (API routes, Server Components)
  • Every callable function checks context.auth as first line
  • Firebase App Check enabled in production with reCAPTCHA or Play Integrity
  • Cloud Functions use minimum required IAM roles
  • Firestore rules tested with firebase emulators exec
  • storage.rules mirror firestore.rules ownership checks
  • Service account keys never in client bundle or public repo
  • Anonymous auth disabled unless you actually need it

Starter config

// firestore.rules — ownership + tenant scoping pattern
rules_version = '2';
service cloud.firestore {
  match /databases/{db}/documents {
    // Default deny
    match /{document=**} { allow read, write: if false; }

    match /users/{userId} {
      allow read: if request.auth != null && request.auth.uid == userId;
      allow write: if request.auth != null && request.auth.uid == userId;
    }

    match /orders/{orderId} {
      allow read: if request.auth != null
        && resource.data.userId == request.auth.uid
        && resource.data.tenantId == request.auth.token.tenantId;
      allow create: if request.auth != null
        && request.resource.data.userId == request.auth.uid;
      allow update, delete: if false; // mutations only via callable function
    }
  }
}

// functions/src/createOrder.ts — callable with auth check
import { https } from "firebase-functions";

export const createOrder = https.onCall(async (data, context) => {
  if (!context.auth) throw new https.HttpsError("unauthenticated", "login required");
  if (!context.auth.token.tenantId) throw new https.HttpsError("permission-denied", "tenant missing");
  // ... create order with userId + tenantId ...
});

// app/api/orders/route.ts — Admin SDK server-only
import { getFirestore } from "firebase-admin/firestore";
export async function GET(req: Request) {
  // auth the request using your own session mechanism first
  const db = getFirestore(); // only runs server-side
  // ...
}