The short version
- In any product where accounts operate several identities — a personal profile, alternate profiles, organization profiles — the acting identity should travel as one explicit parameter (call it actingAs) on every API call: injected automatically on the frontend, verified on the backend.
- Encode type and ownership in the identity id itself. If an alternate profile's id is {primaryUid}__{fragment}, then "may this user act as this alternate" is a string comparison, not a database read. Only the organization check needs a lookup.
- The payoff compounds: API keys, sandboxes, third-party integrations, and AI assistants all eventually ask "on whose behalf?" — and every one of them inherits the answer for free, instead of growing its own permission system.
The problem arrives with organizations
Picture a product that starts simple: one auth account, one profile, one owner on everything. Then it grows the features every product grows. Users want an alternate profile — a different public face for a different context. Organizations arrive, and a user who manages one needs to create and publish as the organization, not as themselves.
Now one human, in one session, creates something under their own name in the morning and publishes under the organization in the afternoon. The organization's content must never go out under their personal profile, and their personal activity must never leak onto the organization's page.
The dangerous way to support this is ambient state: a "current profile" the app holds somewhere, with every endpoint assuming the auth uid is the actor. That works until the first feature where it doesn't — and by then "who is performing this action" is wired as "who is logged in" across every endpoint you own. Retrofitting the distinction means touching all of them.
The pattern, and the whole article, is one decision made early: "who is acting" is never ambient. It's a parameter — on every single call.
Three identity types, one string format
Make every identity a string id, and make the id itself say what it is:
| Identity | Format | Who may act as it |
|---|---|---|
| Primary profile | {uid} — the auth uid itself | Its own user, always |
| Alternate profile | {primaryUid}__{fragment} | The user whose uid is the prefix |
| Organization | {orgId} | Anyone with granted org access |
The underscore encoding looks like a hack and is actually the design. An alternate profile's id contains its owner. Which means two of the three permission checks never touch the database:
- Acting as your primary profile? The id equals your auth uid. String comparison.
- Acting as an alternate? Extract the prefix, compare to your auth uid. String comparison.
- Acting as an organization? The one case that earns a lookup — is this user on the organization's access list, with a role that allows this?
Three identities, three checks — try it
Signed in as auth uid u_84h2 — act as…
actingAs
u_84h2
the permission check
actingAs === auth.uid
allowedstring comparison — no database read
Identity checks run on every authenticated request, so a hot path of two string comparisons and only occasionally a read isn't a micro-optimization — it's what makes checking everywhere affordable, which is what makes checking everywhere happen.
Wrap the format in helpers from day one so nobody parses strings by hand: an inferIdentityType(id) that says which of the three it is, an extractPrimaryUid(id) that pulls the owner out of an alternate. If the format ever changes, it changes in one file.
One parameter, injected once, verified once
The pattern has three parts, each living in exactly one place.
Injected once, verified once
- 1The switcher sets one valuefrontendOne piece of state holds the active profile. No screen threads it anywhere.
- 2The wrapper injects actingAsfrontendEvery API call carries it automatically, read from one selector. Screens can't forget what they never pass.
- 3One helper verifiesbackendcanUserActAsIdentity(auth.uid, actingAs) — defaulting to the auth uid when the parameter is omitted.
- 4Below the check, actingAs IS the callerbackendOwnership, membership, authorship all key on the identity. The auth uid should not appear again.
The frontend injects it. Every API wrapper passes actingAs automatically, read from a single selector that knows the active profile. Screens don't pass it — they can't forget it. When the user switches profiles, the selector changes, and every subsequent call acts as the new identity. No component is involved; no screen needs to know which identity is active, because the wrapper always does.
The backend verifies it. Every handler resolves the acting identity the same way:
async function handler(request, auth) {
const actingAs = request.actingAs || auth.uid // default: yourself
const permCheck = await canUserActAsIdentity(auth.uid, actingAs)
if (!permCheck.allowed) {
throw new AuthError(permCheck.reason)
}
// from here down, `actingAs` IS the caller.
// auth.uid should not appear again.
}Everything downstream keys on actingAs, never on the auth uid. This is the rule that takes discipline, because the auth uid is right there and usually works — for the primary-profile case, which is the case every test covers. The classic bug looks like this:
// Wrong — pins the query to the auth account, not the acting identity
.where("ownerId", "==", auth.uid)
// Right — the identity owns things; the account only proves access to identities
.where("ownerId", "==", actingAs)Write the wrong version and the organization's content shows up while the user is browsing as themselves — and vanishes when they switch to the organization. The bug report says "things randomly disappear." It's never random. It's one query keyed on the wrong concept.
The auth-uid bug — try it
Browsing as
Query keyed on
.where("ownerId", "==", auth.uid)
- Your personal postowner: u_84h2 · visible
- The organization's announcementowner: org_7fq1 · missing from the list
the bug report says "things randomly disappear" — it's one query keyed on the wrong concept
The payoff arrives with every later feature
The reason this pattern earns an article is what happens after it exists. Features built years later inherit multi-identity support without asking for it:
- API keys belong to identities. Mint a key while acting as the organization and the key sees the organization's data — the key stores the identity it was created under, and the gateway resolves it like any other caller. "Org-scoped API keys" requires zero additional design.
- Sandboxes provision identities. A developer sandbox's throwaway owner and members are just identities with no privileges elsewhere. The sandbox never needs its own concept of a fake user.
- Third-party extensions see one id. An extension asks "who am I scoped to" and gets an identity id — personal or organization, same shape. Org-scoped extensions fall out of the identity model, not out of extension code.
- An AI assistant acts as the connected identity. When chat connects to the product, every tool call carries the identity the user authorized — the same
actingAsresolution, one more caller.
Features built years later inherit the answer
actingAs
one answer to "on whose behalf?"
- API keysA key stores the identity it was minted under. Org-scoped keys need zero new design.
- SandboxesThrowaway owners and members are just identities with no privileges elsewhere.
- Extensions“Who am I scoped to” returns an identity id — personal or organization, same shape.
- AI assistantEvery tool call carries the identity the user authorized. One more caller.
That's the compounding return: every feature that asks "who is doing this?" has one answer to integrate with. In the other timeline, those four features each grow their own notion of "on behalf of," and you reconcile four permission systems forever.
The failure modes to expect
- The auth-uid bug, repeatedly. Covered above, and no architecture prevents it — only review culture and a lint-level allergy to the auth uid appearing below the permission check. The ergonomic default (
actingAs || auth.uid) makes the primary case work when callers omit the parameter, which is convenient — and exactly why the bug hides so well. - Manual string parsing. The moment the id format exists, someone will check
id.includes("__")inline, and each copy is one refactor away from wrong. Treat the helpers as sacred so the format stays an implementation detail with exactly one implementation. - Identity documents drifting. If display data lives per-identity, an update applied to one identity's documents but not a sibling's produces profiles that disagree about their own name. Route updates through one helper that knows every document an identity owns.
Steal this workflow
- The day you add organizations — or better, before — decide that actions carry an identity, as one explicit parameter on everything. Ambient identity is a bug class shipped as architecture.
- Encode type and ownership in the identity id. Permission checks that are string comparisons are checks you'll actually run everywhere.
- Inject
actingAsin one frontend wrapper; verify it in one backend helper; default it to the auth uid for ergonomics. - Below the check, ban the auth uid. Ownership keys on the identity.
- Wrap the id format in helpers from day one.
- When later features need "on whose behalf" — keys, sandboxes, extensions, AI — hand them the identity id and nothing else.
FAQ
Because the check runs on every request, and a string prefix can't be out of sync with itself. A table mapping alternates to owners is one more thing to keep consistent; the encoding makes the common checks free and reserves the database for the organization case, which genuinely needs policy.

Written by
Rushit Jivani
Senior Software Architect & AI Application Engineer. Architecture that shows up in crash rate, performance and how fast the team ships.