Skip to content

Guide · Firebase

Roles and permissions for Firebase apps

Security rules and custom claims handle “signed in” and “is an admin”. When users need different roles on different workspaces or projects, keep those grants in Permio and check them from Cloud Functions.

Uses Firebase Auth, Cloud Firestore and Cloud Functions (2nd gen), with the permio TypeScript SDK. Updated November 2026.

Where security rules and custom claims stop

Firebase apps usually read Firestore straight from the client, and security rules decide what each request can do. Rules are excellent for ownership (request.auth.uid == resource.data.ownerId), and custom claims on the ID token cover app-wide roles like admin.

Both struggle with roles per resource. Custom claims are global to the user, limited to 1,000 bytes, and only reach the client when the ID token refreshes, which can take up to an hour. Per-workspace roles in rules mean membership documents read with get(), and each lookup is a billed document read, with a limit of 10 per request (20 for batched writes and transactions). There’s no way to ask “who can see this project?”, and rules can’t call out to another service.

Permio takes the role model out of your rules and claims. A grant on /workspaces/42 reaches every project in that workspace, including ones created later, and a change takes effect on the very next check: there’s no token to refresh.

How it fits together

The client calls a callable Cloud Function. Firebase verifies the user’s ID token and passes the UID to the function, which asks Permio whether that user may do the action, and only then reads or writes Firestore with the Admin SDK. Security rules deny direct client access to these collections, so the functions are the only way in.

The example is a team app: workspaces that contain projects. Resource URIs follow that shape, /workspaces/{workspaceId} and /workspaces/{workspaceId}/projects/{projectId}, so a role on a workspace covers all of its projects, and a role on one project covers only that project.

1. Set up Permio

Sign up and use the default project. A Permio project is a container for your permissions, roles and grants, not one of the example app’s projects. In the dashboard, create these permissions:

Permissions for the example app
PermissionLets the user
project.readOpen a project and see it in lists
project.writeRename or edit a project
workspace.manageCreate projects, invite people, change roles

Then create three roles that bundle them:

Roles for the example app
RolePermissions
viewerproject.read
editorproject.read,project.write
ownerproject.read,project.write,workspace.manage

Grants refer to roles by ID; the code below looks the IDs up by name, so there’s nothing to copy. Open API Keys and create a full-access key, because the app grants roles as well as checking them. Copy it now; it’s only shown once. See API keys for check-only keys and key hygiene.

2. Store the key as a secret

Put the API key in Cloud Secret Manager. Functions that declare the secret can read it at runtime. To use the key in the local emulator too, also put PERMIO_API_KEY=… in functions/.secret.local, and keep that file out of git.

firebase functions:secrets:set PERMIO_API_KEY

3. Create the client

Secret values are only available while a function runs, so create the client on first use and reuse it after that. The same file looks up role IDs by name, and validates input.

functions/src/permio.ts

import { defineSecret } from "firebase-functions/params";
import { HttpsError, type CallableRequest } from "firebase-functions/v2/https";
import { Permio } from "permio";

export const permioApiKey = defineSecret("PERMIO_API_KEY");

let client: Permio | undefined;

// "default" is the Permio project that holds your grants, not one of your app's projects.
export function permio(): Permio {
  client ??= new Permio({ apiKey: permioApiKey.value(), project: "default" });
  return client;
}

// Grants take role IDs. Look them up by name once per instance.
let roleIds: Map<string, string> | undefined;
export async function roleId(name: string): Promise<string> {
  if (!roleIds) {
    const found = new Map<string, string>();
    for await (const role of permio().listRoles()) found.set(role.name, role.id);
    roleIds = found;
  }
  const id = roleIds.get(name);
  if (!id) throw new Error("No Permio role named " + name);
  return id;
}

// The signed-in user's UID, the Permio principal.
export function uid(request: CallableRequest): string {
  if (!request.auth) throw new HttpsError("unauthenticated", "Sign in first.");
  return request.auth.uid;
}

// A non-empty string from the client, or an invalid-argument error.
export function text(value: unknown): string {
  if (typeof value !== "string" || value === "") {
    throw new HttpsError("invalid-argument", "Missing or invalid argument.");
  }
  return value;
}

4. Check before you touch data

Each callable function names the resource it touches and asks Permio. resource() builds the URI and percent-encodes the IDs, so build URIs with it everywhere. Every function that calls Permio lists the secret in secrets.

functions/src/projects.ts

import { initializeApp } from "firebase-admin/app";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { HttpsError, onCall } from "firebase-functions/v2/https";
import { resource } from "permio";
import { permio, permioApiKey, roleId, text, uid } from "./permio";

initializeApp();
const db = getFirestore();
const workspaces = db.collection("workspaces");

export const getProject = onCall({ secrets: [permioApiKey] }, async (request) => {
  const workspaceId = text(request.data?.workspaceId);
  const projectId = text(request.data?.projectId);
  const allowed = await permio().check({
    principal: uid(request),
    resource: resource("workspaces", workspaceId, "projects", projectId),
    permission: "project.read",
  });
  if (!allowed) throw new HttpsError("permission-denied", "You can't open this project.");

  const snap = await workspaces.doc(workspaceId).collection("projects").doc(projectId).get();
  if (!snap.exists) throw new HttpsError("not-found", "No such project.");
  return { id: snap.id, ...snap.data() };
});

functions/src/index.ts

// Firebase deploys only what index.ts exports.
export * from "./projects";

A function that changes a project checks project.write instead. Firestore timestamps reach the client as plain objects, so convert them (for example with .toMillis()) if the client needs dates. In the app, call the function by name:

import { getFunctions, httpsCallable } from "firebase/functions";

const getProject = httpsCallable(getFunctions(), "getProject");
const { data: project } = await getProject({ workspaceId, projectId });

5. Grant roles: owner on create, then invites

Whoever creates a workspace gets the owner role on it, and so on every project inside it. If the grant fails, delete the document so you never keep a workspace nobody can open.

functions/src/projects.ts

export const createWorkspace = onCall({ secrets: [permioApiKey] }, async (request) => {
  const userId = uid(request);
  const ref = await workspaces.add({
    name: text(request.data?.name),
    createdBy: userId,
    createdAt: FieldValue.serverTimestamp(),
  });

  try {
    await permio().grant({
      principal: userId,
      resource: resource("workspaces", ref.id),
      role: await roleId("owner"),
    });
  } catch (err) {
    await ref.delete();
    throw err;
  }
  return { id: ref.id };
});

Firebase Auth can look people up by email, so an invite can take an address. Only someone with workspace.manage may invite, and the grant goes on the workspace, or on one project’s URI to share just that project.

functions/src/projects.ts

import { getAuth } from "firebase-admin/auth";

export const inviteMember = onCall({ secrets: [permioApiKey] }, async (request) => {
  const workspace = resource("workspaces", text(request.data?.workspaceId));
  const role = text(request.data?.role);
  if (role !== "viewer" && role !== "editor") {
    throw new HttpsError("invalid-argument", "Unknown role.");
  }

  const allowed = await permio().check({
    principal: uid(request),
    resource: workspace,
    permission: "workspace.manage",
  });
  if (!allowed) throw new HttpsError("permission-denied", "You can't invite people here.");

  // Throws auth/user-not-found if they haven't signed up yet: store the invite and grant on first sign-in.
  const member = await getAuth().getUserByEmail(text(request.data?.email));
  await permio().grant({ principal: member.uid, resource: workspace, role: await roleId(role) });
  return { ok: true };
});

6. Filter lists to what the user can see

Firestore can’t join against your grants, so ask Permio first. listAccess tells you whether the user can read every project in the workspace (coversAll, so query the collection as usual) or only some, which you fetch by ID in one getAll.

functions/src/projects.ts

export const listProjects = onCall({ secrets: [permioApiKey] }, async (request) => {
  const workspaceId = text(request.data?.workspaceId);
  const projects = workspaces.doc(workspaceId).collection("projects");
  const access = await permio().listAccess({
    principal: uid(request),
    permission: "project.read",
    under: resource("workspaces", workspaceId, "projects"),
  });

  if (access.coversAll) {
    const snap = await projects.orderBy("createdAt").get();
    return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
  }

  // Projects shared one by one: "/workspaces/{w}/projects/{p}".
  // Grants made with a "_" wildcard aren't prefixes; they're left out (safe, not shown).
  const ids = access.toPrefixes()
    .map((uri) => uri.split("/"))
    .filter((s) => s.length === 5)
    .map((s) => decodeURIComponent(s[4]));
  if (ids.length === 0) return [];
  const snaps = await db.getAll(...ids.map((id) => projects.doc(id)));
  return snaps.filter((s) => s.exists).map((s) => ({ id: s.id, ...s.data() }));
});

7. Keep the client out of these collections

Security rules add up: if any rule allows a request, it goes through, and a deny can’t take that back. A catch-all like match /{document=**} { allow read, write: if request.auth != null; }, common in starter projects, would quietly cancel the lock below. So replace your rules file rather than adding to it, and make sure no broader match covers these collections.

firestore.rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Workspaces, projects and everything inside them: Cloud Functions only.
    // Nothing else in this file may match these paths.
    match /workspaces/{document=**} {
      allow read, write: if false;
    }

    // One-user data can still be read straight from the client.
    match /users/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }
  }
}

The Admin SDK in your functions isn’t subject to rules, so it still works. Deploy with firebase deploy --only functions,firestore:rules, then try reading a workspace from the client: it should be refused. If you need live updates on shared data, keep reads in functions and push change notifications to a per-user document the client can listen to.

Let your AI assistant do it

Building with Cursor, Claude Code, Lovable, Bolt or v0? Paste this prompt and it will make the changes above in your own code, using your data model.

Prompt
Add authorisation to this Firebase app with Permio (perms.io), using the `permio` npm package.

Read these first:
- https://perms.io/guides/firebase (how Permio fits Firebase Auth, Firestore and Cloud Functions)
- https://perms.io/llms-full.txt (model, APIs and SDK usage)
- https://docs.perms.io/sdk/typescript/

Rules:
- Keep our existing login. The Permio principal is the signed-in user's ID.
- The Permio API key is a server-side secret. Never put it in client code or a public env var.
- Resource URIs follow our data model, e.g. /workspaces/{id}/projects/{id}, built with the SDK's resource() helper.
- "Project" in the Permio client options is a Permio project (a container for grants), not one of our app's projects.
- Grants take role IDs: look them up by name with permio.listRoles() once and cache them.
- A denied check is a normal answer: return 403 (or the platform's permission-denied error), not a 500.
- Before moving existing queries, show me the plan: which reads and writes move to the server and which stay.

Then:
1. Create functions/src/permio.ts: PERMIO_API_KEY with defineSecret, a lazily created Permio client, a cached role-ID lookup by name, input validation, and a helper that returns request.auth.uid or throws unauthenticated.
2. Move reads and writes of shared data (workspaces, projects and anything inside them) into 2nd gen callable functions (onCall with secrets: [permioApiKey]) that call permio().check() before using the Admin SDK. Throw HttpsError("permission-denied") when denied. Export every function from functions/src/index.ts.
3. When a workspace is created, grant its creator the owner role on /workspaces/{id}. Delete the document if the grant fails. Add an inviteMember function that checks workspace.manage, looks the invitee up by email and grants viewer or editor.
4. Filter list functions with permio().listAccess() and coversAll / toPrefixes(), then fetch with db.getAll(), not by loading every document.
5. Rewrite firestore.rules so those collections deny all client access. Rules are OR-combined: REMOVE any catch-all or broader allow (such as match /{document=**}) that covers them, or the deny does nothing. Keep owner-only rules for one-user data.
6. Update the frontend to use httpsCallable instead of reading those collections directly.
Finally, list the permissions and roles I need to create in the Permio dashboard.

Frequently asked questions

Why not use custom claims for roles?

Custom claims are fine for a handful of app-wide roles such as admin. They are global to the user, limited to 1,000 bytes, and only reach the client when the ID token refreshes. Roles that differ per workspace or project, sharing and “who has access” belong in Permio.

Can Firestore security rules call Permio?

No. Rules can only read Firestore documents and the request; they can’t make HTTP calls. Check Permio in a Cloud Function and use the Admin SDK, which isn’t subject to rules.

What is the principal ID for a Firebase user?

The user’s UID from Firebase Auth (request.auth.uid in a callable function). Permio doesn’t need to know about users in advance; the first grant to a UID is enough.

Does this work with 1st gen Cloud Functions?

Yes, on any Node runtime Cloud Functions still supports (Node 20 or later). In 1st gen, declare the key with runWith({ secrets: ["PERMIO_API_KEY"] }) and read it from process.env, and read the user from context.auth.uid.

Do I lose realtime listeners?

For the collections that go through functions, yes. Keep listeners for one-user data, or notify clients of changes through a per-user document they can listen to and then fetch through a function.

Try it on your own app. The free plan needs no payment details, and the quickstart gets you to a passing check in a few minutes.

Further reading