+1 (762) 572-1994

Custom Claims or Role Documents? Modeling Authorization in Firebase

Roles are where most Firebase authorization models go wrong. Not because the rules language is weak, but because teams pick a storage location for the role — the ID token or a Firestore document — without looking at what that choice costs them in reads, in latency, and in how long a revoked admin stays an admin.

There are exactly two places to put it, and one hybrid. Here is how each behaves in production.

Option 1: custom claims on the ID token

Custom claims are key/value pairs you attach to a user with the Admin SDK. They ride along inside the ID token and appear in rules as request.auth.token.

// server-side only — Admin SDK, never the client
await getAuth().setCustomUserClaims(uid, { role: 'admin', orgId: 'org_42' });
match /orgs/{orgId}/invoices/{invoiceId} {
  allow read: if request.auth.token.orgId == orgId;
  allow write: if request.auth.token.orgId == orgId
            && request.auth.token.role == 'admin';
}

What this costs: nothing. The claim is already inside the token the client presented. No document read, no added latency, no billable operation. For a rule evaluated on every read in a busy app, "free" is not a small detail — see the arithmetic below.

What it costs you elsewhere:

  • Propagation lag. Claims are baked into the token at mint time. An existing ID token keeps the old claims until it refreshes, which is up to an hour. Revoke someone's admin role at 10:00 and they may still be an admin at 10:55.
  • A hard size limit. The total custom-claims payload is capped at 1000 bytes. A list of 300 project IDs does not fit. A role string and an org ID fit comfortably.
  • Server-only writes. Setting claims requires the Admin SDK, so every role change needs a privileged path — a callable function, an admin console, a provisioning job.

Forcing the refresh

You can cut the lag to near zero if you tell the client to refresh after a role change. Write a small signal document the client watches, then force-refresh:

await setDoc(doc(db, 'users', uid, 'meta', 'claims'), { updatedAt: serverTimestamp() });
onSnapshot(doc(db, 'users', uid, 'meta', 'claims'), async () => {
  await auth.currentUser.getIdToken(true); // true = force refresh
});

For immediate lockout of a compromised account, that is not enough — use getAuth().revokeRefreshTokens(uid) and check auth_time in your rules or backend. Claim refresh handles role changes; token revocation handles incidents. They are different problems.

Option 2: a role document read with get()

The alternative is keeping roles in Firestore and reading them from inside the rule:

function memberRole(orgId) {
  return get(/databases/$(database)/documents/orgs/$(orgId)/members/$(request.auth.uid)).data.role;
}

match /orgs/{orgId}/invoices/{invoiceId} {
  allow read: if memberRole(orgId) != null;
  allow write: if memberRole(orgId) == 'admin';
}

This is instant to change — flip the document, the next request sees it — and has no size ceiling. Memberships across 300 projects are fine.

What it costs: each get() in a rule is a billed document read, on top of the document the user actually wanted. Rules get() calls are cached within a single request, so two references to the same path cost one read, not two. Across requests there is no cache.

Run the numbers before you shrug at that. An app serving 50 reads per session, 40,000 sessions a day, with one role lookup per read: 2,000,000 extra reads per day, 60,000,000 per month. At $0.06 per 100,000 document reads in the us-central region that is roughly $36 a month of pure authorization overhead — doubling the read line on the invoice for that collection. Not fatal. Not nothing either, and it grows exactly in step with traffic.

There are also limits: 10 get()/exists() calls per single-document request, 20 for a query. Nested role hierarchies hit that ceiling faster than teams expect, and the failure is a denied request, not a slow one.

The hybrid we usually land on

Put the slow-changing, small, hot-path facts in claims. Put the fast-changing, unbounded, cold-path facts in documents.

  • Claims: role for the account tier, orgId for the primary tenant, a plan flag if billing gates features. Single tenant, one string each, changes a handful of times per account lifetime.
  • Documents: per-resource sharing ("Bob can edit this doc"), project-level membership lists, anything that changes daily.

Then write rules that check the free thing first. Rules short-circuit left to right, so an || or && ordered correctly skips the get() entirely for the common case:

allow read: if request.auth.token.orgId == orgId
         || exists(/databases/$(database)/documents/docs/$(docId)/shares/$(request.auth.uid));

Members of the org — the overwhelming majority of traffic — never trigger the exists(). Only external shared-link users pay the read. Same access matrix, a fraction of the cost.

Test the revocation path, not just the grant

Whatever model you choose, the test that catches real bugs is the downgrade. In your emulator suite, assert that a context carrying stale claims cannot do the thing you just revoked:

test('a demoted admin cannot delete invoices', async () => {
  const stale = env.authenticatedContext('alice', { role: 'admin', orgId: 'org_42' }).firestore();
  // role document now says 'viewer'
  await assertFails(deleteDoc(doc(stale, 'orgs/org_42/invoices/inv-1')));
});

If that test passes with claims-only rules, you have found your propagation window and can decide, deliberately, whether an hour of stale admin is acceptable for that operation. For invoice deletion it usually is not — which is the signal to move that one rule to a get() and keep everything else on claims.

That is the whole decision, and it is per-operation rather than per-app: how much does this check cost at your read volume, and how long can it be wrong?