Using the Auth0 sub Claim as a Firestore Authorisation Foreign Key
Firebase Firestore already does quiet work in this demo suite. The Anonymous Sessions demo and its storefront cart both persist session state through the same firebase-admin credential, invisibly, as plumbing nobody is meant to notice. This post surfaces that same identifier as its own pattern instead: the Auth0 sub claim used as a stable foreign key into Firestore, where an application-managed membership record decides who has access to a shared account, and recording who did what once they're in.
The gap this fills is a common one in real integrations. A token proves who someone is; it rarely carries enough on its own to decide what they're allowed to do against a specific piece of shared data, especially once that data lives outside Auth0 entirely. Firestore is a reasonable place to put that authorisation state, but only if something ties a document to an identity the application can actually trust, which here means a sub claim off a token the SDK has already verified, not a value lifted from an unchecked request. In this post I will detail the membership and audit-log model behind the Firebase/Firestore demo, the design fix a dead-end seeding flow forced, and the four real bugs an adversarial review caught in the already-shipped code.
The steps we will cover in this post are as follows.
- Identity as a Firestore document key
- Why pre-seeding one test account was a dead end
- What an adversarial review found in code that was already live
- Running the demo
Identity as a Firestore Document Key
The model is deliberately small: one demo account, a memberships subcollection keyed by sub, and an auditLog subcollection appended to on every write.

export async function getMembership(accountId: string, sub: string): Promise<Membership | null> {
const snap = await db.collection('accounts').doc(accountId).collection('memberships').doc(sub).get();
if (!snap.exists) return null;
const data = snap.data()!;
return { sub, role: data.role as MembershipRole };
}Every read and write in this demo goes through firebase-admin, the server-side SDK, never a client-side Firestore SDK with its own security rules. That's a deliberate choice worth stating plainly: Firestore Security Rules don't protect these server-side Admin SDK calls at all, because the Admin SDK bypasses them by design. In this setup, the application code is the authorisation boundary. Nothing stops a future code path from reading or writing accounts/* directly instead of going through getMembership(), which is the honest trade-off of this pattern against a client-side Firestore integration with real rules enforcing the same boundary structurally rather than by convention.
The audit log follows the same sub-as-key logic, attributing every action to the identity that performed it rather than just logging that something happened:
export async function logAccountAction(params: {
accountId: string;
actorSub: string;
actorRole: MembershipRole;
action: string;
target?: string;
}): Promise<void> {
await db
.collection('accounts')
.doc(params.accountId)
.collection('auditLog')
.add({
actorSub: params.actorSub,
actorRole: params.actorRole,
action: params.action,
target: params.target ?? null,
at: new Date().toISOString(),
});
}Why Pre-Seeding One Test Account Was a Dead End
The first version of this demo pre-seeded a single Auth0 test account with an owner membership document and left it there. Sign in as that specific account and the demo worked; sign in as anyone else and it quietly dead-ended on "you're not a member of this account," with no way to proceed. That's a reasonable thing to build first, and a genuinely bad thing to ship as the only path in. A demo that only works for one account you have to already know about is a fixture, not a demonstration of the pattern.
The fix was a self-join endpoint rather than a second seed script. Every visitor joins the same single demo account; the endpoint only ever creates accounts/{demoAccountId}/memberships/{sub} for the signed-in user's own sub if that document doesn't already exist, so any signed-in visitor can create their own membership with no setup step, and the join itself is the first audit-logged action:
const membership = await db.runTransaction(async (tx) => {
const membershipSnap = await tx.get(membershipRef);
if (membershipSnap.exists) {
return { sub, role: membershipSnap.data()!.role as 'owner' | 'member' | 'viewer' };
}
tx.set(membershipRef, { role: 'member' });
const auditRef = accountRef.collection('auditLog').doc();
tx.set(auditRef, {
actorSub: sub,
actorRole: 'member',
action: 'joined_account',
target: null,
at: new Date().toISOString(),
});
return { sub, role: 'member' as const };
});That runTransaction wrapper isn't how the join endpoint originally looked. It's the fix for the first of four bugs an adversarial review found in the already-shipped code.
What an Adversarial Review Found in Code That Was Already Live
This demo had already shipped, working, before it occurred to run the same adversarial review this series has used on plans before shipping it on the code itself. Two independent models, given the deployed routes and asked to find real problems rather than approve the design, caught four things worth naming specifically, because none of them were hypothetical.
- The audit log read the entire collection on every request.
getAuditLog(accountId, limit = 20)sliced the returned array to twenty entries, but the Firestore query behind it had nolimit()at all, so every call fetched, transferred, and sorted the whole collection in memory before discarding everything past the twentieth row. Since any member can append to that collection, this is a cost and latency problem that gets strictly worse the more the demo gets used, not a one-off inefficiency. The fix pushes both the sort and the limit down to Firestore itself:.orderBy('at', 'desc').limit(limit). That works here specifically becauseatis stored as a UTC ISO-8601 string, which sorts correctly as plain text; a FirestoreTimestampfield would be the more conventional choice for anything beyond a demo. - Join wasn't atomic. The original version performed three sequential writes (the account document, the membership document, the audit entry) with no transaction around them. A double-click or a pair of concurrent requests could both observe "not a member yet" and both write a duplicate
joined_accountentry, and a crash between steps could leave someone a member with no audit trail of how they got there. TherunTransactionblock above is the fix: because the membership document is keyed deterministically bysub, a second concurrent call re-reads the first one's already-committed state inside its own transaction and returns early, instead of racing it to a duplicate write. - The write-permission check failed open. The original check denied the request only if
membership.role === 'viewer', which means anything else, a missing field, a typo in a manually seeded record, an unexpected value, was implicitly allowed to write. The correct default for an authorisation check is an allowlist, explicitly permittingownerandmemberand denying everything else; a denylist fails open for exactly the cases nobody tested. - Anonymous sessions could acquire a durable, attributed identity. This suite maintains a separate Auth0 client per demo and correlates them into one session-status sidebar, checking each client's cookie in turn until one resolves to a signed-in identity. That resolver is correct for its original purpose, which only ever needed some valid identity to display. It's the wrong default for granting write access to a named, persistent Firestore record, because the cookie list includes the client backing the Anonymous Sessions demo, and an ephemeral, anonymous session was sufficient to self-join under it and acquire a role that outlives the session entirely. The fix is a second, narrower resolver that excludes that one client, used only by this demo's routes, leaving the original resolver untouched for the sidebar it was actually built for.
All four were fixed and pushed in a single follow-up commit. None of them were visible from the outside; the demo looked correct, worked correctly for the one thing anyone had tried, and was still carrying a cost bug, a race condition, a fail-open check, and an identity-boundary gap underneath that working surface.
Running the Demo
The Firebase/Firestore demo walks through exactly this: sign in, see the honest "not a member" state, press Join as member, and watch a membership document keyed by your own Auth0 sub land in Firestore, with the join itself as the first row in a live audit log underneath it. No pre-seeded account required, no setup step to skip past.
Conclusion
The Firestore side of this pattern is unremarkable on its own, a document keyed by a stable identifier is not a new idea. This demo worked, end to end, for weeks, while carrying four separate defects that only a deliberately adversarial second pass surfaced. If you're putting an external data store behind an Auth0-authenticated identity, running that same adversarial pass against the code you've already shipped, not just the plan you wrote before building it, is worth doing at least once.