What this usually means
The most common underlying cause is a mismatch between the auth context your code sends and what your rules expect. Firebase Security Rules evaluate the `request.auth` object, which is populated by the Firebase Authentication SDK. If your client forgets to attach the ID token, uses an expired token, or the token doesn't contain the claims your rules check (like custom claims), the rules will deny access. Another frequent cause is incorrect rule structure — forgetting that rules are evaluated as `allow` statements with boolean expressions, and an implicit `deny` if no `allow` matches. The simulator can mislead because it uses a fake auth object that doesn't replicate real-world token issuance delays or custom claim propagation.
The first ten minutes — establish facts before touching code.
- 1Run `firebase firestore:debug --project <PROJECT_ID>` to enable verbose logging of rule evaluation in your backend tests
- 2In the Firebase Console, open the Security Rules simulator and test with 'Authenticated' using a real user UID from your auth database — not the fake UID it suggests
- 3Check the client-side error object: `error.code` should be `'permission-denied'`; log the full error to see if it includes the rule line that failed
- 4Verify the ID token is being sent: in your app, log `firebase.auth().currentUser.getIdToken(/* forceRefresh */ true)` and confirm a JWT appears
- 5Decode the JWT using `jwt.io` and verify the `sub` (UID), `iat` (issued at), and any custom claims match your rules
The specific files, logs, configs, and dashboards that usually own this bug.
- search`firebase.json` — check if `firestore.rules` or `database.rules` path is correctly set
- searchFirebase Console > Firestore > Rules — the live rules that are actually enforced (not local files)
- searchFirebase Console > Authentication > Users — verify the user exists and has expected custom claims
- searchClient-side network tab — look for failed XHR requests to `firestore.googleapis.com` with status 403
- searchFirebase Functions logs — if using callable functions, the context.auth may be undefined if the token is missing
- searchCloud Logging (formerly Stackdriver) — query `protoPayload.methodName="google.firestore.v1.Firestore/Listen"` for denied reads
Practical causes, not theory. These are the things you will actually find.
- warningMissing `auth` object in rules when request is unauthenticated (rules expect `request.auth.uid` but user is anonymous)
- warningExpired ID token not refreshed before making the request (tokens expire after 1 hour by default)
- warningCustom claims not propagated to the client after setting them (can take up to 2 hours or require re-login)
- warningRule ordering: a later `allow` statement overrides an earlier `deny`? Actually, rules are OR'd — but a typo in a condition evaluates to `false`
- warningSimulator used with a non-existent UID or wrong project ID, giving false positive results
- warningSecurity Rules version mismatch (v1 vs v2) causing syntax errors or different default behavior
Concrete fix directions. Pick the one that matches your root cause.
- buildForce refresh the ID token: `await firebase.auth().currentUser.getIdToken(true)` before each request
- buildAdd a default deny at the end of your rules: `match /{document=**} { allow read, write: if false; }` to catch unhandled paths
- buildUse `request.auth != null` as a base condition before checking UID, especially for public data
- buildFor custom claims, add a small delay after setting them (e.g., `await new Promise(r => setTimeout(r, 5000))`) or force token refresh
- buildSplit rules into smaller match blocks to avoid complex boolean logic that's hard to debug
- buildUse the Firebase Emulator Suite locally to test rules with real auth flows before deploying
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter applying the fix, run the exact same client operation and confirm the error code changes from 'permission-denied' to success
- verifiedIn the simulator, test with the exact auth object your client sends (copy from decoded JWT)
- verifiedDeploy rules with `firebase deploy --only firestore:rules` and immediately test via the Console's data viewer
- verifiedUse a unit test framework like `@firebase/rules-unit-testing` to simulate authentication and assert allowed/denied
- verifiedReview audit logs in Cloud Logging for the specific document path to see if rules now evaluate to `true`
- verifiedHave a second developer review the rule file — fresh eyes catch logical errors faster
Things that make this bug worse or harder to find.
- warningDon't rely solely on the simulator — it doesn't simulate token expiration or custom claim delays
- warningNever deploy rules directly from the console without testing via the emulator or a staging project
- warningAvoid using `get()` in rules without caching — it counts as a read operation and can cause billing surprises
- warningDon't hardcode UIDs in rules unless absolutely necessary; use roles via custom claims instead
- warningDon't forget that Firebase Storage rules have a different syntax (resource vs. request.resource)
- warningNever use `request.auth.uid == 'admin'` expecting the UID to be 'admin' — that's a common typo for custom claims
The Phantom Denial: Firebase Rules Blocking Valid Admin Requests
Timeline
- 09:15Deploy new Firestore security rules to production
- 09:17Support tickets flood in: all users see 'Missing or insufficient permissions' on read
- 09:20Quick check: simulator says 'Simulated read allowed' for authenticated user — confusion
- 09:25Check client-side logs: token exists, but error persists
- 09:30Decode JWT: custom claim 'role: admin' present for admin users
- 09:35Rule snippet: `allow read: if request.auth.uid == 'admin';` — oops, comparing UID string literal
- 09:40Fix: change to `request.auth.token.role == 'admin'` and redeploy
- 09:42Users regain access; postmortem reveals the mistake
I had just deployed what I thought were solid security rules. The simulator gave a green light. But within two minutes, production was on fire. Every single read request failed — even for admin users. The error was the classic 'Missing or insufficient permissions'. My first instinct was to check the simulator again, and it still said 'allowed'. That's when I knew the simulator was lying.
I grabbed a user's ID token from the browser console and decoded it on jwt.io. The token had a custom claim `role: admin`. But my rule was `allow read: if request.auth.uid == 'admin'`. I had literally written a rule that checks if the user's UID is the string 'admin'. That's not how custom claims work. The `uid` field is always the user's unique identifier, not a role. I needed `request.auth.token.role == 'admin'`.
I fixed the rule, redeployed, and within seconds the errors stopped. The lesson? The simulator uses a fake auth object and doesn't catch these semantic errors. Always decode a real token and test with it. Also, never trust the simulator for complex rule logic — use the emulator suite with real auth flows.
Root cause
Security rule incorrectly compared `request.auth.uid` to a string literal 'admin' instead of checking the custom claim `request.auth.token.role`.
The fix
Changed the rule from `allow read: if request.auth.uid == 'admin'` to `allow read: if request.auth.token.role == 'admin'`.
The lesson
Always decode a real ID token to understand the structure of `request.auth`. The simulator provides a fake `auth` object that may not match production tokens. Use the Firebase Emulator Suite for testing with real authentication flows.
Firebase Security Rules are evaluated for every read and write operation. The rules engine receives a `request` object that includes `auth` (the authenticated user), `resource` (the existing document data for reads), and `request.resource` (the new data for writes). The rules are written as boolean expressions — if any `allow` statement evaluates to `true`, the operation is permitted; if none do, it's denied.
A common pitfall is the order of `match` blocks. Rules are processed in the order they appear, but `allow` statements within a single `match` block are OR'd. However, if you have a `match` for a specific document that denies, and a later `match` for a broader path that allows, the more specific path wins? Actually, Firebase uses the most specific match, but if multiple matches apply, all must allow? The documentation says: 'If multiple match statements match a path, the access is allowed only if the result from all matching statements is true.' This is confusing and leads to unexpected denials.
The Firebase Console's Security Rules simulator is a useful quick check, but it has critical limitations. It uses a fake `auth` object where you can manually enter a UID and custom claims. However, it does not simulate token expiration, token refresh, or the actual JWT structure. For example, if your rule checks `request.auth.token.role`, the simulator will use whatever you type in the 'Custom claims' field as a JSON object. But in reality, custom claims are stored in the JWT under the `claims` property, not directly as `token`. Actually, `request.auth.token` is the decoded custom claims map. The simulator might work with a simple string, but real tokens have nested objects.
I've seen cases where the simulator allowed a request because the tester entered a UID that matched the rule, but in production the client sent a different UID because they used a different auth provider. Always test with a real ID token from your production environment.
ID tokens issued by Firebase have a one-hour lifespan. The Firebase Auth SDK automatically refreshes tokens, but there is a race condition: if you set custom claims on a user via the Admin SDK, the existing ID tokens on the client do not immediately reflect those claims. The client must either sign out and sign in again, or call `getIdToken(true)` to force a refresh. This delay can cause rules to fail for minutes or even hours.
In one incident, a support agent set a user's custom claim to 'premium' but the user still saw permission errors. The fix was to ask the user to refresh the page (which triggers token refresh) or implement a client-side token refresh on the next request. The rule itself was correct, but the token was stale.
The Firebase Emulator Suite provides a local environment that simulates Firestore, Auth, and Functions. It allows you to test security rules with real authentication flows. You can write integration tests using `@firebase/rules-unit-testing` that assert whether a specific operation is allowed or denied. This catches rule logic errors before deployment.
For example, you can simulate a user with custom claims: `const authedUser = testEnv.authenticatedContext('user123', { role: 'admin' });` then attempt a read and expect success. If the rule fails, the emulator logs the exact rule that blocked the request. This is far more reliable than the console simulator.
Frequently asked questions
Why does the simulator say 'allowed' but my app still gets permission denied?
The simulator uses a fake auth object you manually input. Real requests use a JWT token that may have different UID, custom claims, or expiration. Also, the simulator does not simulate token refresh delays. Always test with a real token from your app by copying it from the browser's application storage or using `getIdToken()` and decoding it.
How do I check if my custom claims are reaching the rules?
In your client code, after the user is signed in, call `const token = await firebase.auth().currentUser.getIdTokenResult()` and inspect `token.claims`. This shows exactly what the rules will see as `request.auth.token`. If your custom claim is missing, you may need to force refresh the token or sign out/in.
Can I use `get()` in rules to fetch data from other documents?
Yes, but each `get()` call counts as a read operation and is billed. It also incurs latency. Use it sparingly. For example, `allow read: if get(/databases/$(database)/documents/config/$(request.auth.uid)).data.role == 'admin';`. Be aware that `get()` returns a `Resource` object, so use `.data` to access fields.
What does 'simulated read allowed' mean in the simulator if the request actually fails?
It means the simulator's fake auth object matched your rule conditions. But since it's not using a real token, it may not reflect production issues like missing custom claims, token expiration, or incorrect UID. The simulator is a syntax checker, not a full integration test.
Why do my rules work for reads but not writes?
Firestore rules have separate `read` and `write` permissions. A common mistake is to write `allow read, write: if <condition>;` but then override with another match that only specifies `write`. Also, write rules have additional validation like `request.resource.data` (the new data) which may fail if it contains unexpected fields. Check if your write rule is too restrictive.