Skip to content
← All posts
HIPAAFirebaseHealthtechArchitecture

RBAC for Healthcare Apps with Firebase Custom Claims: Roles, Tenants, and the 1,000-Byte Limit

Most writing about HIPAA tells you what the rule requires. Very little of it shows you the implementation. This is the implementation, on the stack a lot of health startups actually use.

I currently serve as fractional CTO of a HIPAA-compliant platform that runs AI analysis over retinal imaging. Access control over protected health information is the part of that system I would least like to get wrong, and Firebase custom claims are how it is enforced. Here is what that looks like, including the three things that surprised me.

Why custom claims rather than a permissions collection

The obvious approach is a permissions collection: look up the user, read their roles, decide. It works, and it is what most tutorials show.

It has a property you do not want in a regulated system. The decision happens in your application code, which means every read path needs to remember to make it. One controller that forgets, one new endpoint written in a hurry, one query that skips the check — and you have disclosed a record. Security that depends on remembering is not a control; it is an intention.

Custom claims move the decision into the database. A claim set with the Admin SDK is signed into the user’s ID token. Firestore security rules can read it directly from request.auth.token with no additional document read, so the rule evaluates on every single request, from every client, whether or not your application code remembered anything.

// Server-side, Cloud Function. Never settable from a client.
await admin.auth().setCustomUserClaims(uid, {
  role: 'clinician',        // one of: patient | clinician | admin
  tid: 'org_4f2b',          // tenant
});
// firestore.rules — the enforcement boundary
match /tenants/{tid}/patients/{patientId}/observations/{doc} {
  allow read: if request.auth != null
              && request.auth.token.tid == tid
              && (
                   request.auth.token.role in ['clinician', 'admin']
                   || request.auth.uid == patientId
                 );
  allow write: if request.auth != null
               && request.auth.token.tid == tid
               && request.auth.token.role in ['clinician', 'admin'];
}

Note what that rule asserts: the tenant in the token must equal the tenant in the path. Not a field on the document — the path. More on why below.

Surprise one: 1,000 bytes goes fast

The custom claims payload has to serialise to under 1,000 bytes. That sounds like plenty until you try to model real permissions.

The instinct is to put a permission map in the claim — which resources, which actions, per tenant. A clinician belonging to three organisations with granular per-resource permissions blows the limit quickly, and when you exceed it the write fails, which if you are unlucky you discover in production.

What works: keep the claim small and categorical. A short role string and a tenant identifier. Use abbreviated keys (tid, not tenantId) — at this budget, key names are a meaningful fraction of your space.

Anything richer lives in Firestore and gets read from inside the rule when it is genuinely needed:

function membership() {
  return get(/databases/$(database)/documents/tenants/$(tid)/members/$(request.auth.uid)).data;
}

allow read: if request.auth.token.tid == tid
            && membership().permissions.hasAny(['read:observations']);

That get() is a billed document read and it adds latency, so use it for the genuinely fine-grained cases and let the claim handle the coarse cut. In practice the coarse cut — right tenant, right role class — eliminates most of the surface, and the expensive check runs only where the granularity actually matters.

Surprise two: claims do not take effect immediately

This is the one that matters most for a regulated system, and it is easy to miss because it works fine in testing.

A custom claim is embedded in the ID token. Tokens refresh on roughly an hourly cycle. So when you revoke someone’s clinician role, they keep it until their token refreshes.

For an ordinary SaaS app, an hour of stale permissions is a nuisance. For PHI access after an employee is terminated, it is a finding.

Two mechanisms, and you need both:

// Client: force a refresh so the new claim applies now.
await auth.currentUser.getIdToken(/* forceRefresh */ true);

That covers the cooperative case — a role change while the user is active. It does not cover revocation, because a revoked user has no incentive to cooperate and may not have a live client at all. For that:

// Server: invalidate outstanding refresh tokens.
await admin.auth().revokeRefreshTokens(uid);

Then have your rules reject tokens issued before the revocation time, so an existing ID token cannot be used until it is re-minted. Write the revocation path before you need it. The day you need it, you will need it quickly and under stress.

Surprise three: path-scoping beats field-scoping

You can scope tenancy two ways. Put tenantId as a field on each document and filter queries by it, or put the tenant in the document path.

Field-scoping fails open. A query that forgets its tenant filter returns everything the rule allows, and rules evaluating a query have to be written to constrain it — which is a different and more error-prone exercise than constraining a document read. Get it slightly wrong and a listing endpoint returns another organisation’s patients.

Path-scoping fails closed. /tenants/{tid}/patients/... with a rule asserting request.auth.token.tid == tid cannot return cross-tenant data, because the tenant is structurally part of the address. There is no query you can write that accidentally spans tenants.

The cost is that migrating a record between tenants means moving the document rather than updating a field. In healthcare that is a rare, deliberate, audited operation anyway — which is arguably how it should feel.

What this does not give you

Being precise about the boundary, because conflating these is how teams end up believing they are more compliant than they are:

  • Custom claims are authorisation, not audit. Knowing a request was permitted is not the same as recording that it happened. You need a separate, queryable audit trail of who read which record and when — retained long enough to answer a regulator, and searchable under pressure rather than merely written.
  • Rules do not cover your backend. Admin SDK calls bypass security rules entirely, by design. Every Cloud Function touching PHI has to enforce its own checks, and that is now application code with all the remembering-to-do-it problems described above. Centralise it in one middleware and review changes to it carefully.
  • This is not a compliance program. Access control is one control among many. BAA coverage across every vendor that can touch PHI, encryption posture, de-identification for analytics, breach procedures — none of that is solved by a security rule.
  • None of this is legal advice. I implement to requirements; your counsel sets them.

The short version

Put the coarse decision — tenant and role class — in a custom claim, keep it under 1,000 bytes with abbreviated keys, and enforce it in security rules so the database refuses rather than trusting your application to remember. Put the tenant in the path, not in a field. Build the forced-refresh and token-revocation paths before you need them.

The property worth optimising for: a new engineer writing a new query should not be able to leak a record by forgetting something. If your access control depends on people remembering, it will hold right up until the week someone is in a hurry.


I’m a fractional CTO working mostly in healthtech and applied AI. If you’re building something where the consequence of a missed check is a disclosure, the 30-minute intro call is free, and the Technology Health Check is where this gets looked at properly.

Frequently asked questions

Can Firebase custom claims be used for HIPAA role-based access control? +

Yes, and they are usually the right primitive. Custom claims are set server-side by the Admin SDK, signed into the user's ID token, and readable inside Firestore security rules without an additional read. That means access decisions happen at the database layer rather than in application code, which is the property you want when the consequence of a missed check is a PHI disclosure. The constraints to design around are the 1,000-byte token limit and the propagation delay when claims change.

What is the size limit on Firebase custom claims? +

The custom claims payload must stay under 1,000 bytes once serialised. That sounds generous until you try to store a per-tenant permission map. In practice you store a short role identifier and a tenant identifier, and keep anything larger in Firestore, read from within security rules via get() where genuinely needed.

How long does it take for a custom claim change to take effect? +

Not immediately. The claim is embedded in the ID token, and tokens are refreshed roughly hourly. A revoked role remains valid until the token refreshes unless you force it. For access revocation in a regulated system that lag is unacceptable, so you force a token refresh client-side and, for immediate hard revocation, also revoke refresh tokens server-side.

Should authorisation live in security rules or in application code? +

In security rules, as the enforcement boundary. Application code can and should also check, for user experience reasons, but a check that only exists in application code is one forgotten conditional away from disclosing a record. Rules are evaluated by the database on every request regardless of which client is asking.

How do you handle multi-tenancy for healthcare data in Firestore? +

Put the tenant identifier in the custom claim and in the document path, then make every rule assert that the two match. Scoping by a field on the document rather than by path is workable but easier to get wrong, because a query missing its tenant filter fails open at the application layer in a way a path-scoped rule cannot.

Have a project in mind?

Book a 30-min technical review

Free 30-minute technical review

No pitch. Straight answer.

Start