Scientyfic World

Firebase Realtime Database Security Rules: Patterns, Pitfalls and Tests

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

Share:

Get an AI summary of this article

Firebase Security Rules for Realtime Database feature image

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.

How Firebase Security Rules evaluate a client request

The Basics: .read, .write, .validate and .indexOn

KeyWhat it doesCascades to children?
.readAllows reading this path and everything below itYes
.writeAllows writing (including deleting) this path and belowYes
.validateChecks that new data has the right shape; runs after .write grants accessNo
.indexOnTells the database to index child keys used in queriesNot 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 === $uid restricts both read and write to the owner.
  • createdAt must equal now, which the client sets with serverTimestamp(); it cannot be backdated.
  • "$other": { ".validate": false } rejects unexpected fields, so a client cannot sneak in something like isAdmin: 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 authorId is 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. .indexOn makes 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 9

The “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

  1. Deny everything at the root and grant access as deep as possible.
  2. Remember cascading: a grant at a parent cannot be undone below it.
  3. Use data for current state and newData for incoming writes.
  4. Validate types, lengths and required fields, and reject unknown fields with $other.
  5. Use server time (now) for timestamps.
  6. Allow list reads only through query-based rules, and add .indexOn for queried fields.
  7. Use custom claims for roles; never trust role fields written by clients.
  8. 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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts