Identity Bridge: Firebase Auth ↔ PostgreSQL
The Acctz platform uses Firebase Authentication as the identity provider and PostgreSQL as the authorization and data layer. This page documents how the two systems connect, the role of the iam.users bridge table, how Row Level Security is activated per-request, and how role permissions are resolved.
The Problem This Solves
The Acctz UI authenticates users via Firebase Auth. All financial data lives in PostgreSQL. The identity bridge answers: how does a Firebase JWT become a PostgreSQL user context that RLS can enforce?
Firebase Auth issues a signed JWT identifying who the user is. PostgreSQL's Row Level Security enforces what data they can see — but only once the Node.js service sets app.current_user_id in the session. The bridge maps the Firebase UID to a local UUIDv7 and activates that context on every request.
Architecture
Request Lifecycle
Every authenticated API request follows this sequence:
1. Client Obtains Firebase JWT
The React app authenticates via Firebase Auth (email/password or Google). Firebase returns a signed JWT containing:
{
"sub": "firebase-uid-abc123",
"email": "user@example.com",
"name": "Jane Smith",
"iss": "https://securetoken.google.com/acctz-project-id",
"exp": 1735689600
}
The JWT is sent on every API request in the Authorization header:
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
2. Node.js Verifies the JWT
An auth middleware intercepts the request, extracts the Bearer token, and verifies it using the Firebase Admin SDK. This validates the signature, issuer, expiration, and audience claims. No credentials ever reach the Node.js service -- only the signed token.
3. Resolve Firebase UID → PostgreSQL User
The middleware extracts uid and email from the decoded token and queries:
SELECT id FROM iam.users WHERE firebase_uid = 'firebase-uid-abc123';
On first login, if no row exists, the backend creates one:
INSERT INTO iam.users (id, firebase_uid, email, display_name, status, last_login_at)
VALUES (gen_uuidv7(), 'firebase-uid-abc123', 'user@example.com', 'Jane Smith', 'active', now());
The id returned is a UUIDv7 -- this is the identity used for all subsequent operations.
4. Activate Row Level Security Context
Before executing any business logic, the backend sets a session-local variable:
SET LOCAL app.current_user_id = '0192d4e0-7b1a-7000-8abc-def012345678';
SET LOCAL scopes the variable to the current database transaction. When the transaction commits or rolls back, the variable disappears. This means there is no risk of session bleed between requests in a connection pool.
5. RLS Filters Automatically
Every tenant-scoped table has a policy like:
CREATE POLICY tenant_isolation ON ledger.accounts
USING (entity_id IN (SELECT ledger.accessible_entity_ids()));
The accessible_entity_ids() function reads app.current_user_id and returns only the entity IDs that user can access through their organization memberships:
CREATE FUNCTION ledger.accessible_entity_ids() RETURNS SETOF UUID AS $$
SELECT e.id FROM ledger.entities e
JOIN iam.org_memberships m ON m.org_id = e.org_id
WHERE m.user_id = current_setting('app.current_user_id', true)::UUID
AND m.status = 'active';
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
The result: every SELECT, UPDATE, and DELETE is automatically filtered to the current user's organizations and entities. Application code does not need to add WHERE entity_id IN (...) clauses -- PostgreSQL enforces it.
The iam.users Bridge Table
This table is the single point of contact between Firebase and PostgreSQL:
CREATE TABLE iam.users (
id UUID PRIMARY KEY, -- UUIDv7, used everywhere in PostgreSQL
firebase_uid VARCHAR(128) UNIQUE, -- Firebase Auth UID, used for JWT lookup
email VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(255),
avatar_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Why a Bridge Table Instead of Using Firebase UID Directly?
-
Independence -- If the identity provider changes (Firebase → Auth0 → Keycloak), only the
firebase_uidcolumn needs to be remapped. Every other table referencesusers.id. -
UUIDv7 consistency -- All PostgreSQL foreign keys use UUIDv7. Firebase UIDs are opaque strings (28-char base64). Mixing types would break index performance and complicate joins.
-
Admin capability -- Admins can manage users in PostgreSQL (suspend, reassign roles) without touching Firebase. The database is the authority on what a user can do; Firebase only knows who they are.
-
Audit trail -- The
audit.audit_log.user_idreferences the PostgreSQL UUID, making audit queries simple joins instead of cross-system lookups.
Organization → Entity → Data Flow
Once a user is authenticated, the data access chain is:
A user can be a member of multiple organizations (e.g., an accountant managing several clients). Each organization can have multiple entities (e.g., separate books for different business units). RLS ensures a user only sees entities belonging to organizations where they have an active membership.
Role Resolution
After identifying the user, the application layer checks permissions before executing write operations:
User → org_memberships → role_id → role_permissions → (resource, action)
For example, can user uuid-abc approve a journal entry in org uuid-org1?
SELECT 1 FROM iam.org_memberships m
JOIN iam.role_permissions rp ON rp.role_id = m.role_id
WHERE m.user_id = 'uuid-abc'
AND m.org_id = 'uuid-org1'
AND m.status = 'active'
AND rp.resource = 'journal_entries'
AND rp.action = 'approve';
If the query returns a row, the action is authorized. If not, the API returns 403 Forbidden.
RLS handles visibility (can you see it?). Role permissions handle capability (can you do something to it?).
Security Considerations
| Concern | Mitigation |
|---|---|
| JWT tampering | Firebase Admin SDK verifies RSA signature against Google's public keys |
| Token expiration | Firebase JWTs expire after 1 hour; the Admin SDK rejects expired tokens |
| Session bleed in connection pool | SET LOCAL scopes the user ID variable to the current transaction only |
| Orphaned Firebase users | Periodic sync job can reconcile Firebase user list with iam.users |
| RLS bypass | The application connects as a non-superuser role; only postgres superuser bypasses RLS |
| Direct database access | pgAdmin/psql connections without SET app.current_user_id see zero rows on RLS-protected tables |
Further Reading
- Database Design -- full ERD, table catalog, triggers, and indexes
- Design Decisions -- rationale for UUIDv7, RLS, schema layout, and more
- MVP Architecture -- how Node.js, PostgreSQL, and Firestore fit together