HomeGuidesFirebase custom claims for admin roles: 8 mistakes, tested

Guide

Firebase custom claims for admin roles: 8 mistakes, tested

How to set a Firebase admin custom claim safely, why request.auth.token.admin stays stale for up to an hour, and how to remove admin for real. Emulator tested.

17 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You asked Cursor, Lovable, Bolt or Claude Code for an admin panel on Firebase. The usual result is a role field on the user's document, an if (user.role === 'admin') in React, and Firestore rules that let users edit their own document. That lets any user make themselves an admin. Firebase's answer is custom claims, but claims have their own traps: they are set only by your server, they arrive late, they leave late, and one call can wipe the others.

This guide covers eight mistakes we see in AI-written Firebase admin roles, what happened when we tested each one in the Firebase Local Emulator Suite, and the fixes. If your admin page only hides itself in the browser, start with client-side auth is not security. If your rules still say if true, fix that first with Firestore allow read, write: if true.

Tested on October 29, 2026 with the Firebase Local Emulator Suite only: firebase-tools 15.34.2 (Auth emulator), the Firestore emulator v1.22.0, the Storage emulator rules runtime v1.1.3, firebase-admin 13.10.0 and the firebase JS SDK 13.1.0 on Node 22.23.4 (firebase-tools now needs Java 21; it refused Java 17). We did not use a hosted Firebase project, so anything about production token signing, refresh after revocation, and the console comes from Firebase's docs, not from us. VibeLocked is independent and not affiliated with Google or Firebase.

The short version

  1. Only the Admin SDK sets claims. The client cannot, but your own endpoint that calls setCustomUserClaims can be abused if it does not check the caller.
  2. A role field the user can write is a self-service admin button. Block it with hasOnly or affectedKeys.
  3. An email check is not an admin check. Anyone can register your admin email before you do unless you also require email_verified.
  4. New claims arrive late. Nothing changes until the ID token refreshes, up to an hour.
  5. Removed claims leave late. An old token stays admin until it refreshes. Revoke tokens and check revocation.
  6. setCustomUserClaims replaces every claim. Merge, or you delete the user's other claims.
  7. Claims are small and public. 1000 bytes, readable by the user, some names reserved.
  8. Your server must verify the token itself. Never trust a role sent in the request body.

1. Only the Admin SDK sets claims, so guard the code that calls it

Firebase's custom claims page says claims "should only be set from a privileged server environment by the Firebase Admin SDK." The client SDKs have no method for it. We checked the REST path a curious user might try: accounts:update with their own ID token and customAttributes: {"admin": true} returned 400 INSUFFICIENT_PERMISSION from the Auth emulator, and the user's claims stayed empty. The same request changing only displayName returned 200, so users can edit their own profile, just not their claims.

The client can read its own claims with getIdTokenResult(). In our test a user saw role: "editor" in their own token. Never put anything in a claim the user should not see.

So the risk is not the client SDK. It is the endpoint, Cloud Function or Server Action your AI tool writes around setCustomUserClaims: a makeAdmin callable with no caller check, or one that checks a flag sent in the request body. Every place that sets claims must first verify the caller's ID token and check that the caller is already an admin. The route in section 8 does that. If the code lives in a Cloud Function, remember that a callable runs even with no signed in user and an onRequest function checks no token at all; Firebase Cloud Functions auth shows the checks for both.

2. A role field the user can write

This is the most common bug, and it looks like it runs on the server. The tool stores role: 'admin' on users/{uid}, writes a rule that lets owners write their own document, and then checks the role with get():

// BAD: the owner can write every field, including role
match /users/{uid} {
  allow read, write: if request.auth != null && request.auth.uid == uid;
}
match /reports/{doc} {
  allow read: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin';
}

In the emulator, a normal user ran setDoc(doc(db, 'users/' + uid), { displayName: 'm', role: 'admin' }). It was allowed, and the next read of the admin collection succeeded. No console, no server, just the client SDK with the public config from your bundle.

The fix is to list the fields a user may write, on create and on update. Firebase's field rules page shows both hasOnly() on keys() and diff().affectedKeys():

match /users/{uid} {
  allow read: if request.auth != null && request.auth.uid == uid;
  allow create: if request.auth != null && request.auth.uid == uid
    && request.resource.data.keys().hasOnly(['displayName', 'photoURL']);
  allow update: if request.auth != null && request.auth.uid == uid
    && request.resource.data.diff(resource.data).affectedKeys()
         .hasOnly(['displayName', 'photoURL']);
}
Write by the owner, with the fixed rule Result
Create with role: 'admin' Denied
Create with only displayName Allowed
updateDoc with role: 'admin' Denied
setDoc with { role: 'admin' }, { merge: true } Denied
updateDoc with isAdmin: true (a field nobody planned) Denied
updateDoc with displayName Allowed, also after the server set role
setDoc without merge that drops the server's role field Denied (removing a key counts as affecting it)

A rule that only blocks role would have missed isAdmin. An allow list catches fields you never thought of.

3. An email is not an admin check

The other shortcut is request.auth.token.email == 'you@yourdomain.com' in rules. In our test a different person created an email and password account with that address. Their token had email_verified: false, and the email-only rule let them read the admin data. The rule with && request.auth.token.email_verified == true denied them. Once they held the address, the real owner's own sign-up failed with auth/email-already-in-use.

If you match on email at all, require email_verified == true, and prefer a claim tied to a user id. The first admin script below refuses unverified accounts for the same reason.

4. New claims arrive late

Claims live inside the ID token, and the client keeps its token until it expires. The custom claims page lists three ways new claims reach the client: a new sign in, a refresh after the old token expires, or currentUser.getIdToken(true). Firebase's session docs say ID tokens "last for an hour."

Step (user already signed in) Result
Server calls setCustomUserClaims(uid, { admin: true }) getUser() on the server shows {"admin":true} at once
Client getIdTokenResult() (no force) claims.admin is undefined
Firestore read guarded by request.auth.token.admin == true Denied
Client getIdTokenResult(true) claims.admin is true, token lifetime 3,600 s
Same Firestore read Allowed (the Firestore SDK picked up the new token by itself)
Storage upload to admin/ with a request.auth.token.admin rule Allowed

Our server route also returned 403 for a newly made admin until they refreshed, because verifyIdToken reads the same token. So after you grant a role, have the admin UI call getIdToken(true) or ask the user to sign out and in again. Firebase's docs show a fancier version that writes a refreshTime to the database and has the client force a refresh when it changes.

A missing claim is not an error you need to handle in rules: request.auth.token.admin == true simply denies when the user has no admin claim. The Storage emulator logged "Property admin is undefined on object" and denied.

5. Removed claims leave late

This is the part that bites when you fire a contractor or a teammate. Removing the claim does not end the access they already have.

Step Result
setCustomUserClaims(uid, null) getUser() shows {}
Firestore read with the user's current token Still allowed
verifyIdToken(oldToken) on the server decoded.admin still true
Client refreshes its token Denied

The user's app decides when to refresh, so a removed admin who keeps an old token (or simply does not refresh) has up to an hour left. To cut them off now, do three things:

  1. Revoke their refresh tokens with revokeRefreshTokens(uid). The session docs say this signs the user out, and that password resets revoke tokens automatically.
  2. Check revocation on your server with verifyIdToken(idToken, true). The verify ID tokens page notes that plain verifyIdToken "does not check whether or not the token has been revoked." With true, the old token failed with auth/id-token-revoked in our test.
  3. Check revocation in rules. Rules only see the token. The session docs show a Realtime Database rule that compares auth.token.auth_time with a revokeTime your server stores. The same idea works in Firestore:
function notRevoked() {
  let meta = /databases/$(database)/documents/metadata/$(request.auth.uid);
  return !exists(meta) || request.auth.token.auth_time > get(meta).data.revokeTime;
}
match /metadata/{uid} {
  allow read: if request.auth != null && request.auth.uid == uid;
  allow write: if false; // only your server writes here
}

What we measured after revokeRefreshTokens and a revokeTime write (in seconds, from tokensValidAfterTime):

Check with the removed admin's old token Result
Firestore rule with only request.auth.token.admin == true Still allowed
Firestore rule with isAdmin() && notRevoked() Denied
verifyIdToken(token, true) auth/id-token-revoked
The user writing their own metadata document to reset revokeTime Denied
A token refreshed after the revoke (emulator only, see below) Still denied by notRevoked() and by verifyIdToken(token, true), because auth_time stays the original sign in time
A fresh sign in after the revoke, admin granted again Allowed

auth_time is in whole seconds, so wait at least a second between a revoke and a test sign in, or a new session in the same second will look revoked. Each get() and exists() in a rule is a billed document read, even when the rule denies, per Firebase's rule conditions page, so put notRevoked() on admin and sensitive paths rather than everything.

Two emulator differences to know about. The Auth emulator still refreshed the user's token after revokeRefreshTokens; the session docs say production signs the user out instead. And with the emulator on, firebase-admin checks revocation on every verifyIdToken call even without true (we saw it in the SDK source), so a missing true will not show up in emulator tests. Read your server code for it.

6. setCustomUserClaims replaces every claim

The docs put it plainly: "this operation always overwrites the user's existing custom claims." In our test, setCustomUserClaims(uid, { admin: true }) and then setCustomUserClaims(uid, { plan: 'pro' }) left {"plan":"pro"}. The admin claim was gone. An AI-written "upgrade to Pro" function can silently demote your admins, and a "make admin" function can wipe a paid plan.

Read, merge, then write:

const { customClaims = {} } = await adminAuth.getUser(uid);
await adminAuth.setCustomUserClaims(uid, { ...customClaims, admin: true });

// remove only admin
const { admin, ...rest } = customClaims;
await adminAuth.setCustomUserClaims(uid, rest);

Both gave the expected result in our test ({"plan":"pro","admin":true}, then {"plan":"pro"}). Passing null removes all claims.

7. Claims are small, public and partly reserved

  • Size. The payload "must not exceed 1000 bytes." The Admin SDK accepted 998 bytes and rejected 1001 with auth/claims-too-large ("Developer claims payload should not exceed 1000 characters") before calling Auth.
  • Purpose. Firebase says claims "are only used to provide access control" and storing profile data in them is "strongly discouraged," since every request carries the token. Keep admin: true or role: 'editor'; put the rest in Firestore.
  • Reserved names. sub, firebase, iss, aud, auth_time, exp, iat, nonce, acr, amr, at_hash, azp, cnf and c_hash were all rejected with auth/reserved-claim. The SDK accepted email, email_verified, uid and user_id, which collide with fields rules read from the token, so do not use those names for your own claims.
  • Visible. The user can read every claim in their own token. Never put a secret, an internal note or another user's data in one.

8. Verify the token on your server

The custom claims page says backend access "should always be enforced through the ID token after validating it and parsing its claims," and that claims "should not be sent directly to the backend." So no { isAdmin: true } in the request body, and no reading user.customClaims from the browser and posting it. A server helper:

// lib/require-admin.ts (server only)
import { adminAuth } from '@/lib/firebase-admin';

export async function requireAdmin(req: Request) {
  const header = req.headers.get('authorization') ?? '';
  const idToken = header.startsWith('Bearer ') ? header.slice(7) : '';
  if (!idToken) return null;
  try {
    // true = also reject revoked tokens and disabled users
    const decoded = await adminAuth.verifyIdToken(idToken, true);
    return decoded.admin === true ? decoded : null;
  } catch {
    return null;
  }
}

With true, the Admin SDK loads the user record on each call, which is one extra Auth request. That is worth it for admin routes. A Next.js Route Handler that lets an admin grant or remove admin for someone else, merging claims and revoking on removal:

// app/api/admin/role/route.ts
import { adminAuth, adminDb } from '@/lib/firebase-admin';
import { requireAdmin } from '@/lib/require-admin';

export async function POST(req: Request) {
  const caller = await requireAdmin(req);
  if (!caller) return Response.json({ error: 'forbidden' }, { status: 403 });

  const { uid, admin } = await req.json();
  if (typeof uid !== 'string' || typeof admin !== 'boolean') {
    return Response.json({ error: 'bad request' }, { status: 400 });
  }
  if (uid === caller.uid && !admin) {
    return Response.json({ error: 'you cannot remove your own admin role' }, { status: 400 });
  }

  // setCustomUserClaims replaces every claim, so merge with what is there
  const { customClaims = {} } = await adminAuth.getUser(uid);
  const { admin: _old, ...rest } = customClaims;
  await adminAuth.setCustomUserClaims(uid, admin ? { ...rest, admin: true } : rest);

  if (!admin) {
    // end their sessions and tell rules to refuse tokens issued before now
    await adminAuth.revokeRefreshTokens(uid);
    const { tokensValidAfterTime } = await adminAuth.getUser(uid);
    const revokeTime = Math.floor(new Date(tokensValidAfterTime!).getTime() / 1000);
    await adminDb.doc(`metadata/${uid}`).set({ revokeTime });
  }
  await adminDb.collection('adminAudit').add({ by: caller.uid, uid, admin, at: new Date() });
  return Response.json({ ok: true });
}

@/lib/firebase-admin is a server-only file that calls initializeApp() from firebase-admin/app with credentials from the server environment and exports getAuth() and getFirestore(). Never import it in client code, and never ship a service account JSON in your bundle or repo; see keep secrets out of frontend code.

We ran this handler's logic against the emulators by calling POST with real Request objects (not inside a running Next.js server):

Request Result
No token 403
Normal user grants admin to themselves 403
New admin with a token from before the grant 403 until they refreshed
Admin grants admin to a user who has plan: 'pro' 200, claims {"plan":"pro","admin":true}
Admin removes their own admin 400
Admin removes the other user 200, claims {"plan":"pro"}, metadata/{uid} written
Removed admin's old token on the route 403
Removed admin's old token on an isAdmin() && notRevoked() rule Denied

The helper, route and first admin script ran as shown, with relative imports in place of the @/lib alias, and type-check with TypeScript 7.0.2 in strict mode.

How to make the first admin

The chicken and egg problem: the route above needs an admin to call it. AI tools often solve it with a "first user to sign up becomes admin" function or a hardcoded email in a callable. Both can be raced by a stranger. Run a one-off script from your own machine with the Admin SDK instead:

// scripts/grant-first-admin.ts: run once from your own machine or CI, never from the app
import { adminAuth } from '../lib/firebase-admin';

const email = process.argv[2];
if (!email) throw new Error('usage: node grant-first-admin.ts you@yourdomain.com');

const user = await adminAuth.getUserByEmail(email);
if (!user.emailVerified) throw new Error(`${email} has not verified its email. Verify it first.`);
await adminAuth.setCustomUserClaims(user.uid, { ...(user.customClaims ?? {}), admin: true });
console.log(`admin granted to ${user.uid}. Sign out and in again (or call getIdToken(true)).`);

In the emulator it refused the unverified account and granted the claim once the email was verified. Turn on MFA for admin accounts; the login brute force guide covers what Firebase offers.

Storage rules use the same token

Cloud Storage rules read the same request.auth.token. Firebase's Storage rule conditions page covers token claims:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /admin/{allPaths=**} {
      allow read, write: if request.auth != null && request.auth.token.admin == true;
    }
  }
}

In the Storage emulator an admin's upload to admin/ was allowed and a normal user's was denied with storage/unauthorized. The same late arrival and late removal apply. We did not test a revocation check in Storage rules. Owner paths and size and type limits are in Firebase Storage rules.

Custom claim or Firestore role field?

Custom claim Role field read with get()
Who can set it Only the Admin SDK Anyone your rules allow to write that field
Takes effect After the next token refresh (up to an hour) Next request (tested: allowed and removed with no refresh)
Cost in rules None One billed document read per get()
Size 1000 bytes total Normal document limits
User can read it Yes, in their token Only if your rules allow

Claims are the better default for a few fixed roles. A server-written role document suits roles that change often or must end at once, as long as the user cannot write it. Firebase's rules and auth page shows both patterns.

What we tested and what we did not

Tested in the Firebase Local Emulator Suite (Auth, Firestore, Storage) with firebase-admin and the firebase JS SDK: setting, reading, merging and removing claims; client token refresh with and without force; Firestore and Storage rules on request.auth.token.admin and a role string claim; the REST attempt to set claims with a user token; writable and locked role fields; email-only and email_verified rules; the 1000 byte limit and reserved names; revokeRefreshTokens, verifyIdToken(token, true) and the revokeTime rule; and the route and first admin script above, run as code against the emulators.

Not tested: a hosted Firebase project or the Firebase console; production token signing (Auth emulator tokens are unsigned, alg: none, and the emulator docs say production rejects them); production refresh behavior after revocation; the one hour expiry in real time; the route inside a running Next.js app; Cloud Functions triggers; multi-tenancy; Realtime Database rules.

Checklist

  • Admin is a custom claim set only by the Admin SDK on a server or in a script, or a role field users cannot write
  • Every endpoint or function that calls setCustomUserClaims verifies the caller's token and checks they are already an admin
  • User documents allow only listed fields on create and update (hasOnly, affectedKeys)
  • No rule or route treats an email as admin without email_verified == true
  • Claim updates merge with existing claims
  • After granting a role, the client calls getIdToken(true) or the user signs in again
  • Removing admin also calls revokeRefreshTokens and writes revokeTime
  • Admin rules use isAdmin() && notRevoked(); metadata is server-write only
  • Server routes use verifyIdToken(idToken, true) and read decoded.admin, never a role from the request body
  • Claims are short access flags, under 1000 bytes, with no secrets
  • The Admin SDK and service account never reach client code or the repo
  • First admin made by a one-off script, not "first sign-up wins"
  • Rules are tested in the emulator with a normal user, an admin, and a removed admin

Copy-paste prompt for your AI coding tool

Audit how this Firebase app decides who is an admin. Do not print or ask for any keys or service account values. 1) Find every call to setCustomUserClaims, getIdTokenResult, getIdToken, verifyIdToken and revokeRefreshTokens, and every role, isAdmin or admin field in Firestore documents, rules and client code. 2) Show the current Firestore and Storage rules and flag: owner write rules on user documents with no hasOnly or affectedKeys allow list, rules that read a role from a document the user can write, rules that trust request.auth.token.email without email_verified, and admin paths that only check request.auth != null. 3) Flag any endpoint, Cloud Function or Server Action that sets claims without verifying the caller's ID token and checking an admin claim, any code that reads a role from the request body, verifyIdToken calls without checkRevoked true on admin routes, and setCustomUserClaims calls that do not merge existing claims. 4) Propose: claims-based admin rules with a notRevoked() check against a server-only metadata/{uid}.revokeTime, field allow lists on user documents, a server helper using verifyIdToken(idToken, true), a role route that merges claims and revokes on removal, a one-off first admin script, and @firebase/rules-unit-testing tests in the emulator for a normal user, an admin and a removed admin. Show the diff before applying anything.

More prompts: security review prompt for AI coding tools.

Check what your app exposes with LeakCheck

LeakCheck scans a live app URL or a public GitHub repo, read only. On a live app it reads the Firebase config your site ships and tests whether your database answers a stranger who is not signed in, and it flags secrets such as a private key block, which is what a leaked service account file contains. It does not sign in, so it cannot see your custom claims, test whether a normal user can write a role field, or check revocation. A clean scan does not prove your admin roles are safe; the emulator tests above do that.

Sources

Questions

How do I make a user an admin in Firebase?

Call setCustomUserClaims(uid, { admin: true }) from the Firebase Admin SDK on a server or from a one off script on your own machine, never from the app. Then check request.auth.token.admin == true in your Firestore and Storage rules and decoded.admin === true after verifyIdToken on your server. The user only gets the claim after their ID token refreshes.

Can a user set their own Firebase custom claims from the client?

Not through Firebase Auth. The client SDKs have no API for it, and in our emulator test an accounts:update request with the user's own ID token and customAttributes returned 400 INSUFFICIENT_PERMISSION. Users get admin through your code instead: a role field they can write on their user document, a rule that trusts an email, or a server endpoint that sets claims without checking who is calling.

Why is my Firebase custom claim not updating?

Claims live in the ID token, and the client keeps using its current token for up to an hour. In our test, getIdTokenResult() right after setCustomUserClaims still had no admin claim and Firestore still denied the read. getIdToken(true) or getIdTokenResult(true) fetched a new token with the claim and the read worked. Signing out and in again does the same.

Does removing a custom claim take effect immediately?

No. After setCustomUserClaims(uid, null) the user's existing token still passed request.auth.token.admin == true in our test until it was refreshed. To cut access off now, also call revokeRefreshTokens, verify tokens with verifyIdToken(token, true) on your server, and store a revokeTime that your rules compare with request.auth.token.auth_time.

What is the size limit for Firebase custom claims?

1000 bytes for the claims payload. In our test the Admin SDK accepted a 998 byte payload and rejected 1001 bytes with auth/claims-too-large. Keep claims to short access flags such as admin: true or role: 'editor' and put everything else in Firestore.

Should I store the admin role in a custom claim or in a Firestore document?

Both work if the user cannot write the role. A claim is free to check in rules but stays stale until the token refreshes. A role field read with get() takes effect on the next request, but each get() is a billed document read. If you keep the role on the user's own document, your rules must block writes to that field with hasOnly or affectedKeys.