Firestore security rules

Firestore rules are the only thing protecting your CMS data. Without them, anyone on the internet could read and modify your posts, settings, even create user records and grant themselves admin. The

Firestore rules are the only thing protecting your CMS data. Without them, anyone on the internet could read and modify your posts, settings, even create user records and grant themselves admin. The Firebase config (apiKey, projectId, etc.) is public by design — security comes from the rules + the auth domain allowlist, not from the secrecy of the config.

This page gives you the recommended ruleset and explains what it does.

The recommended rules

Open Firebase Console → Firestore Database → Rules and paste this, replacing [email protected] with the email you set as the bootstrap admin user (the one you created in Firebase project setup):

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    // ─── Helpers ──────────────────────────────────────────────────────────
    // Bootstrap admin: email pinned here. The single source of truth.
    function bootstrapAdminEmail() {
      return "[email protected]";
    }

    function isSignedIn() {
      return request.auth != null;
    }

    // Bootstrap admin: signed in and email matches the pinned address.
    function isBootstrapAdmin() {
      return isSignedIn()
        && request.auth.token.email != null
        && request.auth.token.email.lower() == bootstrapAdminEmail();
    }

    // Reads /users/{uid} for the caller. Returns null if no record yet.
    function selfRecord() {
      return exists(/databases/$(database)/documents/users/$(request.auth.uid))
        ? get(/databases/$(database)/documents/users/$(request.auth.uid)).data
        : null;
    }

    function isDisabled() {
      let rec = selfRecord();
      return !isBootstrapAdmin() && rec != null && rec.disabled == true;
    }

    function isAdmin() {
      let rec = selfRecord();
      return isBootstrapAdmin() || (rec != null && rec.role == "admin" && rec.disabled != true);
    }

    function isEditor() {
      let rec = selfRecord();
      return isBootstrapAdmin()
        || (rec != null && (rec.role == "admin" || rec.role == "editor") && rec.disabled != true);
    }

    // ─── users/{uid} ──────────────────────────────────────────────────────
    // - Any signed-in editor can read the list (Users page + author lookup).
    // - First login: self-create allowed if uid matches and role is "editor".
    // - Self update: only `preferences.adminLocale` may change.
    // - Role/disabled changes: admin only.
    match /users/{uid} {
      allow read: if isEditor();

      // Self-create on first login. Regular users may only create
      // themselves with role "editor". The bootstrap admin (email
      // matching the pinned address) may self-create with role
      // "admin" so the UI shows admin features immediately, no manual
      // promotion step needed. Either branch enforces email match and
      // disabled=false.
      allow create: if isSignedIn()
        && request.auth.uid == uid
        && request.resource.data.email == request.auth.token.email.lower()
        && request.resource.data.disabled == false
        && (
          (isBootstrapAdmin() && request.resource.data.role == "admin")
          || (!isBootstrapAdmin() && request.resource.data.role == "editor")
        );

      allow update: if isAdmin()
        || (
          isSignedIn()
          && request.auth.uid == uid
          && !isDisabled()
          && request.resource.data.diff(resource.data).affectedKeys()
              .hasOnly(["preferences", "firstName", "lastName", "bio", "avatarMediaId"])
          && (
            !("preferences" in request.resource.data.diff(resource.data).affectedKeys())
            || request.resource.data.preferences.adminLocale in ["en", "fr", "de", "es", "nl", "pt", "ko"]
          )
        );

      allow delete: if isAdmin();
    }

    // ─── posts/{id} (posts + pages) ───────────────────────────────────────
    match /posts/{id} {
      allow read, write: if isEditor();
    }

    // ─── terms/{id} (categories + tags) ───────────────────────────────────
    match /terms/{id} {
      allow read, write: if isEditor();
    }

    // ─── media/{id} ───────────────────────────────────────────────────────
    match /media/{id} {
      allow read, write: if isEditor();
    }

    // ─── settings/site ────────────────────────────────────────────────────
    match /settings/{docId} {
      allow read: if isEditor();
      allow write: if isEditor();
    }

    // ─── config/admin ─────────────────────────────────────────────────────
    // Empty placeholder probed by the admin client to detect bootstrap-
    // admin status WITHOUT carrying the email in /admin/config.js. The
    // doc itself doesn't need to exist — what matters is the rule: a
    // get() succeeds only when isBootstrapAdmin() returns true.
    match /config/admin {
      allow read: if isBootstrapAdmin();
      allow write: if isAdmin();
    }

    // ─── config/flexweg + other config docs ──────────────────────────────
    // API key — read by every editor (publisher needs it), write admin-only.
    // The `docId != "admin"` guard ensures editors do not read the probe
    // doc above (Firestore rules OR-merge across all matching paths, so
    // without this guard editors would silently pass the bootstrap probe).
    match /config/{docId} {
      allow read: if docId != "admin" && isEditor();
      allow write: if docId != "admin" && isAdmin();
    }

    // ─── Default deny ─────────────────────────────────────────────────────
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

Click Publish at the top of the page.

What the rules do

Bootstrap admin

The very first login is a chicken-and-egg problem: to create a Firestore user record you need to be authenticated, but to be admin you need a user record. The rules solve this by hardcoding one email address that's treated as admin without needing a record:

function bootstrapAdminEmail() {
  return "[email protected]";
}

function isBootstrapAdmin() {
  return isSignedIn()
    && request.auth.token.email != null
    && request.auth.token.email.lower() == bootstrapAdminEmail();
}

You MUST replace [email protected] with the actual email of your bootstrap admin user. Otherwise no one can log in.

You only need ONE bootstrap admin. Additional admins/editors get user records created at first login and don't need to be in the rules.

Self-create on first login

When a user signs in for the very first time, the admin tries to create their users/{uid} document. The rules allow this only if the document is created with a safe shape:

  • uid matches the authenticated user

  • email matches the JWT email

  • disabled is exactly false

  • Role rule:

    • The bootstrap admin (email matching the pinned address) self-creates with role "admin" so the UI shows admin features immediately, no manual promotion step.
    • Regular users self-create with role "editor" only — no privilege escalation possible.

A malicious user can't sign in and grant themselves admin on first login. Only admins (or the bootstrap admin) can promote users afterwards.

Per-collection rules

Collection Read Write
users/{uid} editor admin (or self for profile fields only)
posts/{id} editor editor
terms/{id} editor editor
media/{id} editor editor
settings/{docId} editor editor
config/admin bootstrap admin only admin
config/{docId} (other) editor admin only

config/flexweg (which contains your Flexweg API key) is admin-write only so a junior editor can't rotate / delete the API key by accident. Editors can read it because the publisher needs the key to upload files.

config/admin is a tiny probe document the admin client uses to detect whether the signed-in user is the bootstrap admin — without ever shipping the bootstrap email in the JS bundle. The doc itself doesn't need to exist; only the bootstrap admin's get() succeeds. The docId != "admin" guard on the generic config/{docId} rule prevents editors from accidentally passing this probe (Firestore rules OR-merge across all matching paths).

Default deny

The final block:

match /{document=**} {
  allow read, write: if false;
}

Locks down every other path. New collections you might create need explicit rules — without them they're unreachable. This is the safe default.

Verifying the rules work

After publishing, the Rules Playground in the Firebase Console lets you simulate requests:

  1. Rules tab → Rules Playground (top right).
  2. Pick get as the operation, path /databases/(default)/documents/posts/test-id.
  3. Set Authenticated off → run. You should see denied.
  4. Set Authenticated on, paste your bootstrap admin email in the email field → run. You should see allowed.

If anything is denied when it shouldn't be, you typed the email wrong somewhere.

Updating roles

To promote an editor to admin:

  1. Open Firestore Database → Data → users → <their uid>.
  2. Edit the role field from "editor" to "admin".
  3. Save.

Their next admin reload picks up the new role (the AuthContext refreshes the user record on auth state change).

When the rules block you

If you see permission-denied errors in the admin's browser console:

  1. Open the Rules Playground in the Firebase Console.
  2. Simulate the failing request (the error message tells you the path and operation).
  3. The Playground highlights which rule denied it — fix that rule (or the data shape your code is sending) and re-publish.

If the bootstrap admin email is wrong (typo in the rules), you'll get denied on every write. Fix the email in the rules and re-publish.

Continue