
A Firestore security rule is the only authorization the client SDK will honor. Test mode is world-open until a timestamp.
If the rule is allow read, write: if true, the database is public. If the rule checks request.auth.uid but never the document owner, any signed-in user can read any document they can guess.
The usual mistake is developing against test mode and shipping the same rules because the app ‘required login’ in the UI.
This page is the rule shapes that actually bind a user to a document, and the test mode clock you should not rely on.
Test mode on a new Firestore database is world-open until a timestamp about 30 days out. The console comment says so. After that date the same match denies everyone, which is how teams discover they never wrote rules. allow read, write: if request.auth != null is the next paste. Any signed-in account then reads every note. That is IDOR with a login screen.
The control is a per-document owner compare, a create path that freezes ownerId, and an emulator test with two uids you minted.
This is the same bug as a missing WHERE owner_id = ? in SQL. Read IDOR for the query shape. Keep the secure coding checklist next to both. Client input that becomes a document id is also an input validation problem.
Signed-in is the first gate, not the pass. The pass is the owner uid on the note Alice asked for.
SecureCoding
Locked, test, and signed-in are three different holes
Basic Security Rules. Locked mode on Firestore is:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
}
}
}
That is deny. New projects should stay here until a collection has a named rule. Test mode is not deny. The console writes a comment that the database is open to anyone, and that the allow expires after 30 days because it leaves the app open to attackers. The live condition is request.time < timestamp.date(y, m, d) on /{document=**}. Until that date, unauthenticated clients read and write everything. Extending the date is not a policy. Delete the match.
The third paste is “any signed-in user.” Firebase documents it under development-environment rules and says they do not recommend leaving data accessible to any signed-in user. The snippet is still copied into production:
// BAD: any account that can sign up reads every note
match /{document=**} {
allow read, write: if request.auth != null;
}
request.auth == null is the unauthenticated caller. An allow that is true when auth is null is an open database. Do not write that. Do not write if true on a collection that holds user content. Public-read belongs on a field you named, such as visibility == "public", and only on get, never on write.
| Condition | Who gets in |
|---|---|
if false | Nobody. Locked mode. |
request.time < date | The internet, until the date. |
request.auth != null | Every signed-in uid. |
auth.uid == ownerId | The owner of that document. |
Admin SDK and Cloud Functions use privileged credentials and skip these rules. That is expected. The mobile and web SDKs do not. If the only “backend” is the client talking to Firestore, the rules file is the authorization layer. There is no second check on a server you forgot to write.
Owner on the path or owner on the document
Writing conditions. Two first-party patterns exist. Path owner: the document id is the uid.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, update, delete: if request.auth != null
&& request.auth.uid == userId;
allow create: if request.auth != null
&& request.auth.uid == userId;
}
}
}
Field owner: the document id is a note id the client chose or the SDK minted. Then the uid lives in ownerId.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function ownsNote() {
return signedIn()
&& request.auth.uid == resource.data.ownerId;
}
function createOwnNote() {
return signedIn()
&& request.resource.data.ownerId == request.auth.uid;
}
match /notes/{noteId} {
allow get, list: if ownsNote();
allow create: if createOwnNote();
allow update: if ownsNote() && createOwnNote()
&& request.resource.data.ownerId == resource.data.ownerId;
allow delete: if ownsNote();
}
}
}
resource.data is the existing document. request.resource.data is the document after the write. The update line requires both, so Alice cannot PATCH ownerId to Bob and cannot take Bob’s note. Split read into get and list, and write into create, update, and delete, when the verbs differ. They do, on notes.
Custom claims are for roles, not for “this uid owns this row.” request.auth.token.admin == true is a second function you add next to ownsNote() for a support tool. It does not replace the owner compare on the user path.
Writes: the client must not pick ownerId
Create is where IDOR hides. A rule that only checks request.auth != null on create lets Alice write { ownerId: "bob-uid", body: "..." } into notes/anything. Bob then sees a note he never wrote, or Alice plants a document in a path Bob’s client will trust.
createOwnNote() above forces the new ownerId to equal the token uid. The update line refuses a change to that field. Do not accept ownerId, uid, or userId from a form and “also” check auth. The field is copied from request.auth.uid in client code for convenience. The rule is the enforcement.
Deletes need the existing owner, not the body. A delete has no useful request.resource for this purpose. ownsNote() reads resource.data.ownerId.
List queries must include the same constraint the rule can prove. A collection-wide get that could return Bob is denied in full. Alice lists with where("ownerId", "==", alice-uid). A bare collection("notes").get() fails once ownsNote() is on list.
A query cannot hide Bob’s notes
same conditions page down at the heading that says rules do not filter results. Firestore evaluates the query against its potential result. If any matching document could fail the rule, the whole query fails. That is why the client where-clause must match ownsNote().
// client: alice-uid session
import { collection, query, where, getDocs } from "firebase/firestore";
const notes = collection(db, "notes");
// FAIL: result set could include Bob
await getDocs(notes);
// PASS: every returned row has ownerId == alice-uid
await getDocs(query(notes, where("ownerId", "==", aliceUid)));
You will need a composite index if you add a second where. That is a console click. It is not a reason to loosen list to if signedIn().
get() and exists() inside rules read other documents and are billed. The conditions page caps those calls. Do not look up a user profile on every note read if ownerId is already on the note. Put the uid on the row you are protecting.
Realtime Database .validate and .indexOn do not exist here. If a tutorial uses those keys inside service cloud.firestore, close the tab. This file is Firestore rules version 2.
Prove Alice cannot read Bob
Build unit tests. The v9 library is @firebase/rules-unit-testing. It talks to the emulator only. initializeTestEnvironment, authenticatedContext, unauthenticatedContext, assertSucceeds, and assertFails are the names.
If the emulator starts without a rules file, the docs say it treats the project as open. Point firebase.json at firestore.rules. Load that file into the test env.
import {
assertFails,
assertSucceeds,
initializeTestEnvironment,
} from "@firebase/rules-unit-testing";
import { doc, getDoc, setDoc } from "firebase/firestore";
import { readFileSync } from "fs";
const PROJECT = "demo-notes";
const RULES = readFileSync("firestore.rules", "utf8");
let testEnv;
before(async () => {
testEnv = await initializeTestEnvironment({
projectId: PROJECT,
firestore: { rules: RULES, host: "127.0.0.1", port: 8080 },
});
});
beforeEach(async () => {
await testEnv.clearFirestore();
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, "notes", "note-alice"), {
ownerId: "alice-uid",
body: "mine",
});
await setDoc(doc(db, "notes", "note-bob"), {
ownerId: "bob-uid",
body: "not yours",
});
});
});
after(async () => {
await testEnv.cleanup();
});
it("alice reads her note and is denied Bob", async () => {
const alice = testEnv.authenticatedContext("alice-uid").firestore();
await assertSucceeds(getDoc(doc(alice, "notes", "note-alice")));
await assertFails(getDoc(doc(alice, "notes", "note-bob")));
});
it("signed-out is denied", async () => {
const anon = testEnv.unauthenticatedContext().firestore();
await assertFails(getDoc(doc(anon, "notes", "note-alice")));
});
it("alice cannot create a note owned by bob-uid", async () => {
const alice = testEnv.authenticatedContext("alice-uid").firestore();
await assertFails(
setDoc(doc(alice, "notes", "planted"), {
ownerId: "bob-uid",
body: "plant",
})
);
});
You are not attacking a production project. You are proving your file denies the second uid. A green test that only uses alice-uid on note-alice is incomplete. The Bob get is the IDOR case.
Ship firestore.rules with the app. Review the Rules tab after every console edit. The console will happily accept a test-mode date bump. CI should be the publisher.
Questions we keep getting
Is request.auth != null enough if sign-up is invite only?
No. Invite-only reduces who can mint a token. It does not bind that token to a row. One invited user still walks notes/{noteId} unless ownerId matches. Put the compare on the document.
Do I hide note ids if the rule already checks ownerId?
You can. You still need the compare. Sjoerd’s line on Security Stack Exchange is the whole point: an unguessable id is not access control. Auto-ids stop casual scans. They do not stop a leaked link or a guessed path from a log.
Can Cloud Functions skip the owner rule?
The Admin SDK bypasses rules. A function that takes noteId from the client must do the same uid compare in code, or it becomes the IDOR the rules just closed. Treat the function as another client that happens to have a privileged key.



