Skip to main content

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 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.

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() {
}

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

// Bootstrap admin: signed in, email matches the pinned address,
// AND email is verified. The email_verified check matters because
// Firebase Email/Password sign-in returns `email_verified: false`
// by default — the SetupForm enforces verification on first run,
// and these rules pin it server-side so an unverified token can
// never sign in as bootstrap admin (defends against future bypass).
function isBootstrapAdmin() {
return isSignedIn()
&& request.auth.token.email != null
&& request.auth.token.email_verified == true
&& 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 (verified
// email + matching 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() {
}

function isBootstrapAdmin() {
return isSignedIn()
&& request.auth.token.email != null
&& request.auth.token.email_verified == true
&& 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.

The email_verified check is a defence-in-depth pin: Firebase Email/Password sign-in returns email_verified: false by default, and the in-admin SetupForm enforces verification on first run. Pinning it in the rules ensures an unverified token can never elevate to bootstrap admin even if the client-side guard were bypassed.

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 (verified 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

CollectionReadWrite
users/{uid}editoradmin (or self for profile fields only)
posts/{id}editoreditor
terms/{id}editoreditor
media/{id}editoreditor
settings/{docId}editoreditor
config/adminbootstrap admin onlyadmin
config/{docId} (other)editoradmin 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.

Email is case-sensitive at the JWT level

The rules call .lower() on the JWT email before comparing. Always store the bootstrap admin email lowercase in the rules (e.g. "[email protected]", not "[email protected]"). The admin's UI lowercases emails before submitting them too.

Continue