Unified Auth Architecture — Design Document
Historical note (2026-04-19): sections describing the pre-cutover REST gateway reflect state at time of writing. The gateway has since been retired; current auth runs through Firebase Auth email-link + Cloud Functions. See
docs/architecture_backend-consolidation-2026-04-18.mdfor the as-shipped architecture.
Date: 2026-04-14 (design), 2026-04-16 (updated with implementation)
Status: Implemented — ring-based auth system built in @plantagoai/auth
Author: Dagan Gilat + Claude Code
Problem
Authentication and authorization are currently scattered across multiple mechanisms:
| Site | Auth Method | State Storage | Role Check |
|---|---|---|---|
| foundation-vote.web.app | WebAuthn biometric + JWT | sessionStorage (svote_token) + localStorage (svote_access) |
isAdmin from env vars |
| solanavote-devnet.web.app | Same as above | Same | Same |
| solanavote-yc.web.app | Google OAuth (Firebase Auth) | Firebase Auth SDK | Email allowlist in code |
| solanavote-docs.web.app | Google OAuth (Firebase Auth) | Firebase Auth SDK | Any authenticated user |
| Cloud Run API | X-API-Key header |
Firestore lookup | None (all keys equal) |
| PoH API | X-PoH-API-Key header |
Firestore lookup | Plan-based (free/pro/enterprise) |
This creates:
- Recurring bugs —
isDevnethostname checks, localStorage race conditions, session state mismatches (see: biometric login loop, fixed 4 times) - No tenant isolation — all data is global, no per-org boundaries
- Hardcoded roles — admin detection uses env vars, credential IDs, and hostname heuristics
- No trust tiers — a biometric-only user and a passport-ZK-verified user have identical permissions
- Duplicate auth systems — WebAuthn JWT (custom) and Firebase Auth (Google) are completely separate
As we approach multi-tenant deployment and pilot communities, this doesn't scale.
Design: Firebase Custom Claims as the Single Source of Truth
Core Principle
Every user gets one Firebase Auth account. Their permissions, org membership, and trust level are stored as custom claims in the Firebase ID token — readable by any service without a database call.
Token Structure
Firebase Auth ID Token (JWT)
├── sub: "firebase-uid-abc123"
├── email: "voter@example.com" (if email-based)
├── tenant_id: "austin-tx" (org membership)
├── roles: ["voter"] (permissions)
├── trust_tier: "passport-zk" (identity verification level)
├── poh_nullifier: "0xabc..." (PoH identity, if verified)
└── exp: 1720000000
Trust Tiers (ordered, each includes the rights of the tier below)
| Tier | How Earned | What It Unlocks |
|---|---|---|
anonymous |
Access code or demo login | Browse proposals, view results |
email |
Google/email sign-in | Comment, support proposals |
biometric |
WebAuthn passkey registration | Vote on low-stakes proposals |
passport-zk |
Self Protocol ePassport proof | Vote on all proposals, anonymous ballot, full PoH |
Roles (independent of trust tier)
| Role | Granted By | Permissions |
|---|---|---|
voter |
Default on registration | Vote, support, submit proposals |
reviewer |
Admin assignment | Review proposals, moderate content |
admin |
Tenant admin or super-admin | Manage voters, tools, tenant config |
super-admin |
Platform owner (Dagan) | Cross-tenant access, API key management |
api-consumer |
API key provisioning | PoH API access (external integrators) |
Components
1. Auth Service (Cloud Function)
Single place that creates/upgrades Firebase Auth accounts and sets custom claims.
functions/src/authService.ts
import * as admin from 'firebase-admin';
interface UserClaims {
tenant_id: string;
roles: string[];
trust_tier: 'anonymous' | 'email' | 'biometric' | 'passport-zk';
poh_nullifier?: string;
}
// Called after WebAuthn registration succeeds
export async function onBiometricRegistration(
voterId: string,
tenantId: string,
credentialId: string,
): Promise<string> {
// Create or get Firebase Auth user linked to this voter
let firebaseUid: string;
try {
const existing = await admin.auth().getUserByEmail(`${voterId}@voter.foundation.vote`);
firebaseUid = existing.uid;
} catch {
const created = await admin.auth().createUser({
uid: voterId,
email: `${voterId}@voter.foundation.vote`,
displayName: `Voter ${voterId.slice(0, 8)}`,
});
firebaseUid = created.uid;
}
await admin.auth().setCustomUserClaims(firebaseUid, {
tenant_id: tenantId,
roles: ['voter'],
trust_tier: 'biometric',
} as UserClaims);
// Return a custom token the client can exchange for a Firebase ID token
return admin.auth().createCustomToken(firebaseUid);
}
// Called after Self Protocol passport proof succeeds
export async function onPassportVerified(
voterId: string,
nullifier: string,
trustTier: 'passport-zk',
): Promise<void> {
const user = await admin.auth().getUser(voterId);
const existing = (user.customClaims || {}) as Partial<UserClaims>;
await admin.auth().setCustomUserClaims(voterId, {
...existing,
trust_tier: trustTier,
poh_nullifier: nullifier,
});
}
// Called by tenant admin to grant roles
export async function setUserRole(
adminUid: string,
targetUid: string,
role: string,
action: 'grant' | 'revoke',
): Promise<void> {
// Verify caller is admin for same tenant
const adminUser = await admin.auth().getUser(adminUid);
const adminClaims = adminUser.customClaims as UserClaims;
if (!adminClaims.roles.includes('admin') && !adminClaims.roles.includes('super-admin')) {
throw new Error('Unauthorized: admin role required');
}
const target = await admin.auth().getUser(targetUid);
const targetClaims = (target.customClaims || {}) as Partial<UserClaims>;
// Tenant isolation: admin can only manage users in their tenant
if (adminClaims.tenant_id !== targetClaims.tenant_id &&
!adminClaims.roles.includes('super-admin')) {
throw new Error('Unauthorized: cross-tenant access denied');
}
const roles = new Set(targetClaims.roles || []);
if (action === 'grant') roles.add(role);
else roles.delete(role);
await admin.auth().setCustomUserClaims(targetUid, {
...targetClaims,
roles: Array.from(roles),
});
}
2. Shared Auth Hook (React)
One hook used by all frontend sites. Replaces svote_access, svote_token, VotingContext auth state, and per-site Google Auth.
shared/src/useAuth.ts
import { useState, useEffect, useContext, createContext } from 'react';
import { getAuth, onIdTokenChanged, User } from 'firebase/auth';
export interface AuthUser {
uid: string;
email: string | null;
displayName: string | null;
tenantId: string;
roles: string[];
trustTier: 'anonymous' | 'email' | 'biometric' | 'passport-zk';
pohNullifier?: string;
}
export interface AuthState {
user: AuthUser | null;
loading: boolean;
/** Check if user has a specific role */
can: (role: string) => boolean;
/** Check if user meets minimum trust tier */
meetsMinTrust: (tier: string) => boolean;
/** Firebase ID token for API calls */
getIdToken: () => Promise<string | null>;
}
const TRUST_ORDER = ['anonymous', 'email', 'biometric', 'passport-zk'];
export function useAuthState(): AuthState {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [firebaseUser, setFirebaseUser] = useState<User | null>(null);
useEffect(() => {
const auth = getAuth();
return onIdTokenChanged(auth, async (fbUser) => {
if (fbUser) {
const result = await fbUser.getIdTokenResult();
const claims = result.claims;
setUser({
uid: fbUser.uid,
email: fbUser.email,
displayName: fbUser.displayName,
tenantId: (claims.tenant_id as string) || 'default',
roles: (claims.roles as string[]) || [],
trustTier: (claims.trust_tier as AuthUser['trustTier']) || 'anonymous',
pohNullifier: claims.poh_nullifier as string | undefined,
});
setFirebaseUser(fbUser);
} else {
setUser(null);
setFirebaseUser(null);
}
setLoading(false);
});
}, []);
const can = (role: string) => user?.roles.includes(role) ?? false;
const meetsMinTrust = (tier: string) => {
if (!user) return false;
return TRUST_ORDER.indexOf(user.trustTier) >= TRUST_ORDER.indexOf(tier);
};
const getIdToken = async () => firebaseUser?.getIdToken() ?? null;
return { user, loading, can, meetsMinTrust, getIdToken };
}
export const AuthContext = createContext<AuthState>({
user: null, loading: true,
can: () => false, meetsMinTrust: () => false,
getIdToken: async () => null,
});
export const useAuth = () => useContext(AuthContext);
3. Declarative Gate Components
Replace ad-hoc isAuthenticated / isAdmin checks with composable components.
shared/src/gates.tsx
import { useAuth } from './useAuth';
/** Render children only if user has the specified role */
export function RequireRole({ role, fallback, children }: {
role: string;
fallback?: React.ReactNode;
children: React.ReactNode;
}) {
const { user, can } = useAuth();
if (!user || !can(role)) return fallback ?? null;
return <>{children}</>;
}
/** Render children only if user meets minimum trust tier */
export function RequireTrust({ tier, fallback, children }: {
tier: 'email' | 'biometric' | 'passport-zk';
fallback?: React.ReactNode;
children: React.ReactNode;
}) {
const { user, meetsMinTrust } = useAuth();
if (!user || !meetsMinTrust(tier)) return fallback ?? null;
return <>{children}</>;
}
/** Render children only if user belongs to the specified tenant */
export function RequireTenant({ tenantId, fallback, children }: {
tenantId: string;
fallback?: React.ReactNode;
children: React.ReactNode;
}) {
const { user } = useAuth();
if (!user || user.tenantId !== tenantId) return fallback ?? null;
return <>{children}</>;
}
Usage across sites:
// Main voting app — admin panel
<RequireRole role="admin">
<AdminTestingTab />
</RequireRole>
// Main voting app — anonymous voting requires passport-zk
<RequireTrust tier="passport-zk" fallback={<UpgradePrompt />}>
<AnonymousVoteBooth />
</RequireTrust>
// YC site — restrict to specific reviewers
<RequireRole role="reviewer">
<GrantApplications />
</RequireRole>
// Docs site — any authenticated user
<RequireTrust tier="email">
<Documentation />
</RequireTrust>
4. Firestore Security Rules
Tenant isolation enforced at the database level, not just the UI.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Helper: check caller's tenant
function userTenant() {
return request.auth.token.tenant_id;
}
// Helper: check caller's roles
function hasRole(role) {
return role in request.auth.token.roles;
}
// Helper: check trust tier meets minimum
function meetsTrust(minTier) {
let order = {'anonymous': 0, 'email': 1, 'biometric': 2, 'passport-zk': 3};
return order[request.auth.token.trust_tier] >= order[minTier];
}
// Proposals — scoped to tenant
match /proposals/{id} {
allow read: if request.auth != null
&& userTenant() == resource.data.tenant_id;
allow create: if request.auth != null
&& hasRole('voter')
&& meetsTrust('biometric');
allow update: if hasRole('admin')
&& userTenant() == resource.data.tenant_id;
}
// Votes — scoped to tenant, requires biometric minimum
match /votes/{id} {
allow read: if request.auth != null
&& userTenant() == resource.data.tenant_id;
allow create: if hasRole('voter')
&& meetsTrust('biometric')
&& userTenant() == request.resource.data.tenant_id;
}
// Identity proofs — write-once, server-validated
match /identity_proofs/{nullifier} {
allow read: if request.auth != null;
allow create: if false; // Server-only via Cloud Functions
}
// Tenant config — admin only
match /tenants/{tenantId} {
allow read: if request.auth != null && userTenant() == tenantId;
allow write: if hasRole('admin') && userTenant() == tenantId;
}
// API keys — super-admin only
match /poh_api_keys/{keyId} {
allow read, write: if hasRole('super-admin');
}
}
}
5. Rocket Server (Cloud Run) Validation
The Rust API server validates Firebase ID tokens instead of custom JWTs.
// Validate Firebase ID token from Authorization header
// Firebase ID tokens are standard JWTs signed by Google
// Verify with Google's public keys at:
// https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com
// Extract custom claims for authorization:
// claims.tenant_id → scope queries
// claims.roles → permission checks
// claims.trust_tier → trust-gated operations
Tenant Data Model
tenants/{tenant_id}
├── name: "City of Austin"
├── slug: "austin-tx"
├── domain: "austin.foundation.vote" (optional custom domain)
├── pillars: [1] (which pillars enabled)
├── auth_methods: ["biometric", "email"] (allowed sign-in methods)
├── settings: { ... } (per-tenant config)
├── created_at: "2026-..."
└── owner_uid: "firebase-uid-..."
// All tenant-scoped collections include tenant_id field:
proposals/{id} → { ..., tenant_id: "austin-tx" }
voters/{id} → { ..., tenant_id: "austin-tx" }
votes/{id} → { ..., tenant_id: "austin-tx" }
Migration Path
These phases are ordered by dependency, not time. Each is independently deployable.
| Phase | What Changes | Files Affected | Risk |
|---|---|---|---|
| A | Create Firebase Auth user on WebAuthn registration, return custom token | auth.ts, Cloud Function, BiometricAuth.tsx |
Medium — core auth change |
| B | Move roles to custom claims, remove isAdmin env var heuristics |
VotingContext.tsx, Navbar.tsx, Cloud Function |
Low |
| C | Add tenant_id to custom claims + Firestore security rules |
firestore.rules, Cloud Function, all write paths |
Medium — touches many queries |
| D | Replace svote_access localStorage with Firebase Auth state |
AccessGate.tsx, BiometricAuth.tsx, auth.ts |
Low — removes fragile code |
| E | Extract shared useAuth() hook, use across all sites |
New shared package, all site entry points | Low — additive |
| F | Add <RequireRole> / <RequireTrust> gate components |
All sites, replace ad-hoc checks | Low — incremental |
Phase A Detail (the structural change)
Current flow:
WebAuthn register → REST gateway → custom JWT → sessionStorage
New flow:
WebAuthn register → REST gateway → creates Firebase Auth user → sets custom claims
→ returns Firebase custom token
→ Client exchanges custom token for Firebase ID token
→ Firebase Auth SDK manages session (no manual sessionStorage)
This single change eliminates:
- Manual JWT storage in sessionStorage
- The
svote_token/svote_accesssplit - Module-level JWT restoration race conditions
- The biometric login loop bug (permanently)
Scaling Considerations
| Concern | How This Architecture Handles It |
|---|---|
| Millions of users | Firebase Auth scales natively; no custom session management |
| Hundreds of tenants | tenant_id in claims + security rules; no code change per tenant |
| Cross-site SSO | Same Firebase project, same Auth state across all *.web.app sites |
| API consumers | PoH API keys remain separate (machine-to-machine, not user auth) |
| Offline/degraded | Firebase Auth caches tokens client-side; works offline for reads |
| Custom claims limit | Firebase custom claims max 1000 bytes; our structure uses ~200 bytes |
| Token refresh | Firebase SDK handles automatically (1-hour token lifetime, silent refresh) |
What This Replaces
After full migration, these can be deleted:
evoting-frontend/src/lib/auth.ts— custom JWT management (replaced by Firebase Auth SDK)svote_tokensessionStorage keysvote_accesslocalStorage keyACTIVITY_KEYsession timeout logic (Firebase handles session lifetime)isDevnethostname checks for auth behaviorVITE_ADMIN_WALLETS/VITE_ADMIN_CREDENTIAL_IDSenv vars- Custom JWT generation in REST gateway
/auth/register,/auth/login,/auth/refresh - Per-site auth gates (docs-site inline JS, yc-site
useAuthState)
Implementation: Ring-Based Permission System (April 2026)
The original design proposed role names (voter, admin, super-admin). During implementation, we adopted a ring-based permission system (inspired by CPU protection rings / Linux) to decouple role names from authorization logic. This was necessary because different projects (Foundation, MarketHub, Soho) use different role names for equivalent privilege levels.
Ring Levels
Ring 0 — Platform owner (full system access, cross-tenant)
Ring 1 — Tenant admin (full control within a tenant)
Ring 2 — Privileged user (elevated: moderator, vendor, etc.)
Ring 3 — Standard user (normal authenticated user)
Ring 4 — Restricted (demo, guest, observer)
Lower ring = higher privilege. Ring 0 can do everything.
Per-Project Role Mapping
Each project defines its own role names that map to ring levels:
// Foundation
const foundationRoles = defineRoles({
super_admin: Ring.PLATFORM_OWNER, // 0
admin: Ring.TENANT_ADMIN, // 1
tenant_admin: Ring.PRIVILEGED, // 2
member: Ring.USER, // 3
demo_user: Ring.RESTRICTED, // 4
});
// MarketHub
const marketHubRoles = defineRoles({
super_admin: Ring.PLATFORM_OWNER, // 0
store_admin: Ring.TENANT_ADMIN, // 1
vendor: Ring.PRIVILEGED, // 2
customer: Ring.USER, // 3
});
Custom Claims Structure (Implemented)
{
"ring": 1,
"role": "store_admin",
"tenantId": "acme",
"tenantRings": { "acme": 1, "globex": 3 }
}
Shared Middleware (Implemented in @plantagoai/auth/middleware)
// Project-agnostic — checks ring number, not role name
await requireRing(request, Ring.TENANT_ADMIN); // allows Ring 0 + 1
await requireTenantAccess(request, "acme", Ring.PRIVILEGED); // per-tenant ring check
await requirePlatformOwner(request); // Ring 0 only
Trust Tiers → Ring Mapping
The original trust tiers (anonymous → email → biometric → passport-zk) map naturally to rings but operate on a separate axis. Trust tiers determine what identity verification a user has; rings determine what permissions they have. A user can be Ring 3 (standard) with passport-zk trust (high verification), or Ring 1 (admin) with email trust only.
Open Questions — Resolved
WebAuthn + Firebase Auth linking — Resolved:
createCustomToken()after WebAuthn verification is the correct pattern. Firebase doesn't support WebAuthn as a native provider, and this approach is used in production by other projects.Existing user migration — Resolved post-cutover: the REST gateway was retired in favour of Firebase Auth email-link sign-in, and WebAuthn users were migrated as part of that same phase. (Historical: the original plan was to have the gateway mint Firebase custom tokens.)
Anonymous access — Resolved: Firestore rules use
request.auth != nullfor reads. Anonymous browse is handled at the app layer (show public data without auth gate). Ring 4 (Restricted) covers demo/guest users who do authenticate but with limited permissions.Tenant provisioning — Resolved: Phase 1 uses manual tenant creation (admin creates tenants). Self-service provisioning is Phase 3 (roadmap item, not blocking pilot).
Cross-tenant users — Resolved: Yes, users can belong to multiple tenants. Implemented via
tenantRingsin custom claims — a map of{ tenantId: ringLevel }. A user can be Ring 1 (admin) in one tenant and Ring 3 (member) in another. Platform owners (Ring 0) have access to all tenants automatically.