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.
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, 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() {
return "[email protected]";
}
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:
uidmatches the authenticated useremailmatches the JWT emaildisabledis exactlyfalse- 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.
- The bootstrap admin (verified email matching the pinned address) self-creates with role
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:
- Rules tab → Rules Playground (top right).
- Pick get as the operation, path
/databases/(default)/documents/posts/test-id. - Set Authenticated off → run. You should see
denied. - 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:
- Open Firestore Database → Data → users → <their uid>.
- Edit the
rolefield from"editor"to"admin". - 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:
- Open the Rules Playground in the Firebase Console.
- Simulate the failing request (the error message tells you the path and operation).
- 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.
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
- Flexweg account setup — API key + allowed extensions
- First-run setup form — connect everything together