Firebase Realtime Database Security Rules decide who can read and write each path in your database and what valid data looks like. They run on Firebase’s servers for every client request, so they are the only thing standing between your data and anyone who has your app’s config. This guide covers the rules you need for real apps: per-user data, ownership, roles, validation, state machines and secure queries, plus the two behaviors that trip most people up.
What I tested: every ruleset in this post runs in the Firebase Database emulator with @firebase/rules-unit-testing 6.0 and Firebase JS SDK 13.0, and nine tests pass. Three of them reproduce bugs in the rules from the 2023 version of this post, which I have corrected: one let a signed-out user create anyone’s profile, one made it impossible to create posts, and one froze every order in the “pending” status.

The Basics: .read, .write, .validate and .indexOn
| Key | What it does | Cascades to children? |
|---|---|---|
.read | Allows reading this path and everything below it | Yes |
.write | Allows writing (including deleting) this path and below | Yes |
.validate | Checks that new data has the right shape; runs after .write grants access | No |
.indexOn | Tells the database to index child keys used in queries | Not a permission |
Inside rules you can use auth (the signed-in user, including auth.uid and custom claims in auth.token), data (the current value), newData (the value after the write), root, now (server time in milliseconds), query (the client’s query) and $variables captured from the path.
Two Behaviors That Cause Most Rule Bugs
1. Read and write rules cascade
From Firebase’s rules syntax docs: child rules can only grant additional access, and access granted at a parent “cannot be revoked by a child path”. If you write ".read": true at /users, a stricter rule at /users/$uid does nothing. Start with everything denied at the root and grant access as deep in the tree as you can.
2. Rules are not filters
Reading a path requires permission for the whole path; the database will not quietly return only the children you are allowed to see. In my tests, reading all of /posts failed even though some posts were public. The fix is a query-based rule that allows exactly the query your app makes, shown in the posts section below.
Also note that .validate rules do not cascade and are ignored when data is deleted (a write of null), so put delete permissions in .write.
Start Locked Down
{
"rules": {
".read": false,
".write": false
}
}Never leave a production database in test mode (".read": true, ".write": true). Grant access per path from here.
Per-User Data
Each user owns /users/{uid}. The profile must have a name of 1 to 50 characters and a server-set creation time, and no other fields are allowed:
{
"rules": {
"users": {
"$uid": {
".read": "auth != null && auth.uid === $uid",
".write": "auth != null && auth.uid === $uid",
"profile": {
".validate": "newData.hasChildren(['name', 'createdAt'])",
"name": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 50"
},
"createdAt": {
".validate": "newData.val() === now || newData.val() === data.val()"
},
"$other": {
".validate": false
}
}
}
}
}
}auth != null && auth.uid === $uidrestricts both read and write to the owner.createdAtmust equalnow, which the client sets withserverTimestamp(); it cannot be backdated."$other": { ".validate": false }rejects unexpected fields, so a client cannot sneak in something likeisAdmin: true. My test confirmed that write was rejected.
The 2023 version used ".write": "!data.exists() || data.child('userId').val() === auth.uid" for profiles. Because !data.exists() is true for any new profile, a signed-out user could create a profile for any user ID, and my test confirmed it.
Ownership, Public Content and Secure Queries
Posts have an author, can be public or private, and must not change author after creation:
{
"rules": {
"posts": {
".indexOn": [
"authorId",
"createdAt"
],
".read": "auth != null && query.orderByChild === 'authorId' && query.equalTo === auth.uid",
"$postId": {
".read": "auth != null && (data.child('public').val() === true || data.child('authorId').val() === auth.uid)",
".write": "auth != null && ((!data.exists() && newData.child('authorId').val() === auth.uid) || (data.child('authorId').val() === auth.uid && (!newData.exists() || newData.child('authorId').val() === auth.uid)))",
".validate": "newData.hasChildren(['authorId', 'title', 'public', 'createdAt'])",
"title": {
".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 200"
},
"public": {
".validate": "newData.isBoolean()"
},
"createdAt": {
".validate": "(!data.exists() && newData.val() === now) || newData.val() === data.val()"
},
"authorId": {
".validate": "newData.isString()"
},
"$other": {
".validate": false
}
}
}
}
}- Create: allowed only if there is no post yet and the new
authorIdis the current user. - Edit and delete: only the existing author, and an edit cannot change
authorId. - Read one post: if it is public or yours.
- List posts: the
/posts-level rule allows exactly one query,orderByChild('authorId').equalTo(auth.uid), so users can list their own posts and nothing else..indexOnmakes that query efficient.
The old rule, auth.uid === data.child('authorId').val(), checked the existing data. For a new post there is no existing data, so the check was always false and no one could create a post. Use data for “who owns it now” and newData for “what is being written”.
The client query that matches the list rule:
import { equalTo, get, getDatabase, orderByChild, query, ref } from 'firebase/database';
const myPosts = query(ref(getDatabase(), 'posts'), orderByChild('authorId'), equalTo(user.uid));
const snapshot = await get(myPosts);Roles With Custom Claims
For admin features, set a custom claim on the user’s token from a trusted server with the Admin SDK (for example setCustomUserClaims(uid, { admin: true })) and check it in rules. Claims cannot be set by the client.
{
"rules": {
"admin": {
".read": "auth.token.admin === true",
".write": "auth.token.admin === true"
}
}
}Validating State Changes
Rules can enforce a small state machine. Here users can create their own orders in the pending state, only admins can change the status, and the status can only move forward: pending to paid or cancelled, then paid to shipped. The owner cannot be changed:
{
"rules": {
"orders": {
"$orderId": {
".read": "auth != null && (data.child('ownerId').val() === auth.uid || auth.token.admin === true)",
".write": "auth != null && ((!data.exists() && newData.child('ownerId').val() === auth.uid) || auth.token.admin === true)",
".validate": "newData.hasChildren(['ownerId', 'status'])",
"ownerId": {
".validate": "newData.isString() && (!data.exists() || newData.val() === data.val())"
},
"status": {
".validate": "(!data.exists() && newData.val() === 'pending') || (data.val() === 'pending' && (newData.val() === 'paid' || newData.val() === 'cancelled')) || (data.val() === 'paid' && newData.val() === 'shipped') || newData.val() === data.val()"
}
}
}
}
}The old validation rule, (!data.exists() && newData.val() === 'pending') || (data.exists() && data.val() !== 'pending'), allowed changes only when the current status was not pending. Since every order starts as pending, no order could ever move on. My test confirmed the old rule rejected pending to paid.
Test Your Rules in the Emulator
Testing rules by hand in the console simulator does not scale. The Database emulator and @firebase/rules-unit-testing let you write tests for each user and case. Setup:
npm install -D firebase @firebase/rules-unit-testing firebase-tools{
"database": {
"rules": "database.rules.json"
},
"emulators": {
"database": {
"port": 9000
},
"ui": {
"enabled": false
},
"singleProjectMode": false
}
}The tests, using Node’s built-in test runner:
import { readFileSync } from 'node:fs';
import { after, before, beforeEach, describe, test } from 'node:test';
import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing';
import { equalTo, get, orderByChild, query, ref, remove, serverTimestamp, set, update } from 'firebase/database';
let env;
const db = (uid, claims) => (uid ? env.authenticatedContext(uid, claims) : env.unauthenticatedContext()).database();
const seed = fn => env.withSecurityRulesDisabled(ctx => fn(ctx.database()));
before(async () => {
env = await initializeTestEnvironment({
projectId: 'demo-rtdb-rules',
database: { rules: readFileSync('database.rules.json', 'utf8'), host: '127.0.0.1', port: 9000 },
});
});
beforeEach(() => env.clearDatabase());
after(() => env.cleanup());
describe('users', () => {
test('owner can write a valid profile; others and strangers cannot', async () => {
const profile = { name: 'Ada', createdAt: serverTimestamp() };
await assertSucceeds(set(ref(db('alice'), 'users/alice/profile'), profile));
await assertFails(set(ref(db('bob'), 'users/alice/profile'), profile));
await assertFails(set(ref(db(null), 'users/alice/profile'), profile));
await assertFails(get(ref(db('bob'), 'users/alice')));
});
test('validation rejects bad and extra fields', async () => {
await assertFails(set(ref(db('alice'), 'users/alice/profile'), { name: '', createdAt: serverTimestamp() }));
await assertFails(set(ref(db('alice'), 'users/alice/profile'), { name: 'Ada', createdAt: 123 }));
await assertFails(set(ref(db('alice'), 'users/alice/profile'), { name: 'Ada', createdAt: serverTimestamp(), isAdmin: true }));
});
});
describe('posts', () => {
const post = uid => ({ authorId: uid, title: 'Hello', public: false, createdAt: serverTimestamp() });
test('authors can create, edit and delete their own posts only', async () => {
await assertSucceeds(set(ref(db('alice'), 'posts/p1'), post('alice')));
await assertFails(set(ref(db('bob'), 'posts/p2'), post('alice'))); // cannot post as someone else
await assertSucceeds(update(ref(db('alice'), 'posts/p1'), { title: 'Edited' }));
await assertFails(update(ref(db('alice'), 'posts/p1'), { authorId: 'bob' })); // cannot hand off authorship
await assertFails(update(ref(db('bob'), 'posts/p1'), { title: 'Hacked' }));
await assertFails(remove(ref(db('bob'), 'posts/p1')));
await assertSucceeds(remove(ref(db('alice'), 'posts/p1')));
});
test('rules are not filters: reading /posts needs a query the rules allow', async () => {
await seed(async d => {
await set(ref(d, 'posts/a'), { authorId: 'alice', title: 'A', public: false, createdAt: 1 });
await set(ref(d, 'posts/b'), { authorId: 'bob', title: 'B', public: true, createdAt: 2 });
});
await assertFails(get(ref(db('alice'), 'posts'))); // whole list: denied
const mine = await assertSucceeds(get(query(ref(db('alice'), 'posts'), orderByChild('authorId'), equalTo('alice'))));
if (Object.keys(mine.val()).join() !== 'a') throw new Error('expected only alice’s post');
await assertFails(get(query(ref(db('alice'), 'posts'), orderByChild('authorId'), equalTo('bob'))));
await assertSucceeds(get(ref(db('alice'), 'posts/b'))); // public post by id
});
});
describe('orders', () => {
test('status follows pending -> paid -> shipped, changed only by admins', async () => {
await assertFails(set(ref(db('alice'), 'orders/o1'), { ownerId: 'alice', status: 'paid' }));
await assertSucceeds(set(ref(db('alice'), 'orders/o1'), { ownerId: 'alice', status: 'pending' }));
await assertFails(update(ref(db('alice'), 'orders/o1'), { status: 'paid' })); // owner cannot mark paid
const admin = db('ops', { admin: true });
await assertFails(update(ref(admin, 'orders/o1'), { status: 'shipped' })); // cannot skip a step
await assertSucceeds(update(ref(admin, 'orders/o1'), { status: 'paid' }));
await assertSucceeds(update(ref(admin, 'orders/o1'), { status: 'shipped' }));
await assertFails(update(ref(admin, 'orders/o1'), { status: 'pending' })); // no going back
});
});
describe('admin', () => {
test('custom claim gates access', async () => {
await assertSucceeds(set(ref(db('ops', { admin: true }), 'admin/settings'), { maintenance: false }));
await assertFails(get(ref(db('alice'), 'admin/settings')));
});
});$ npx firebase emulators:exec --only database "node --test --test-concurrency=1 test/rules.test.js test/original-bugs.test.js"
✔ bug 1: a signed-out user can create anyone’s profile
✔ bug 2: nobody can create a new post
✔ bug 3: a pending order can never change status
✔ owner can write a valid profile; others and strangers cannot
✔ validation rejects bad and extra fields
✔ authors can create, edit and delete their own posts only
✔ rules are not filters: reading /posts needs a query the rules allow
✔ status follows pending -> paid -> shipped, changed only by admins
✔ custom claim gates access
ℹ pass 9The “bug” tests assert that the old rules behave badly, which is why they pass. Keep tests like these in CI and run them on every rules change.
Checklist
- Deny everything at the root and grant access as deep as possible.
- Remember cascading: a grant at a parent cannot be undone below it.
- Use
datafor current state andnewDatafor incoming writes. - Validate types, lengths and required fields, and reject unknown fields with
$other. - Use server time (
now) for timestamps. - Allow list reads only through query-based rules, and add
.indexOnfor queried fields. - Use custom claims for roles; never trust role fields written by clients.
- Test every rule change in the emulator before deploying with
firebase deploy --only database.
Conclusion
Good Realtime Database rules are short, start from deny-all, use data and newData correctly, validate everything clients send, and allow lists only through specific queries. The three bugs in this post’s earlier version are common ones, and each was caught in seconds by a test. Write the tests first, and the rules stay honest. For Cloud Firestore and Cloud Storage, which use a different rules language, see Firebase Storage and Firestore rules in practice.
FAQs
Why can a user still read data when my child rule says false?
Read and write rules cascade. If a parent path grants access, a child rule cannot take it away. Move the grant down to the most specific path that needs it.
Why does reading a list fail even though some items are allowed?
Rules are not filters. Reading a path requires permission for the whole path. Allow a specific query instead, for example orderByChild on an owner field with equalTo(auth.uid), using a query-based rule.
Do .validate rules run when data is deleted?
No. Validation rules are ignored when the new value is null, so control deletes with .write rules.
How do I give admins extra access?
Set a custom claim such as admin: true on the user with the Admin SDK from a trusted server, then check auth.token.admin in your rules.
How do I test Realtime Database rules?
Run the Firebase Database emulator and use @firebase/rules-unit-testing to create authenticated and unauthenticated contexts, then assert that reads and writes succeed or fail.
Are Realtime Database rules the same as Firestore rules?
No. Realtime Database uses JSON-based rules with .read, .write and .validate keys, while Firestore and Cloud Storage use a separate rules language with match and allow statements.








