Skip to main content
Back to Portfolio

Cinderblock

Forkable multi-tenant SaaS starter on Supabase + Next.js whose load-bearing differentiator is a 74-test pgtap Row-Level-Security suite against a hostile fixture (5 workspaces × 8 users × every role × every table). Cross-tenant reads, writes, joins, CTEs, UNIONs, aggregates all return zero rows.

Next.js 16React 19Tailwind 4TypeScriptSupabasePostgreSQL 17Row-Level SecuritypgtapStripeEdge Functionspg_cronApache + PM2PlaywrightVitest
Cinderblock preview

Overview

Most Supabase multi-tenant deliveries leak. The failure mode is subtle. A policy passes when the owner reads their own data. It breaks when there's a join across tables. Or a subquery that filters by user_id. Or a view that aggregates rows from multiple workspaces. The first multi-tenant customer to actually try cross-tenant data access — an audit, a pentest, an accidental QA query — finds the hole. The freelancer is long gone.

Cinderblock is a forkable Supabase + Next.js multi-tenant SaaS starter built around the inverse contract: every claim about tenant isolation in the docs is backed by a test in supabase/tests/, and the test suite runs against a deliberately hostile fixture.

The hostile fixture and the 74-test contract:

5 workspaces × 8 users with a deliberate membership matrix — overlapping memberships across workspaces, every role variant, one outsider with zero memberships. The suite authenticates as a user with no business reading a given row and asserts an empty result. Tests fail loudly if a single row leaks. Categories: cross-tenant reads (12), cross-tenant writes (8), join leakage through CTEs / UNIONs / subqueries / aggregates (6), security-definer hardening with an end-to-end search-path attack (5), audit-log integrity (4), service-role + connection-pool safety (7), concurrency + impersonation visibility (8), billing seat-cap + writability + Stripe idempotency (5), task surface (5), invitation flow (5).

Append-only audit log at the Postgres grant level:

The cb_audit_writer Postgres role has INSERT-only grants on audit_events and zero SELECT/UPDATE/DELETE grants anywhere. The Next.js server opens a separate postgres-js connection authenticated as this role for audit writes. Even a compromised application server cannot rewrite history. A DB CHECK constraint backstops the TypeScript guard against self-impersonation rows regardless of role.

Doubly-logged admin impersonation:

Server-minted HS256 JWT signed with SUPABASE_JWT_SECRET overrides the session via a cb_impersonate cookie. RLS evaluates as the impersonated user; every audit row records both actor_id (the target) and impersonator_id (the admin), pulled directly from JWT claims. 6-digit OTP step-up before mint. Hard 60-minute expiry, no refresh path. The admin's normal session is preserved untouched in the sb-* cookies.

Insert-first Stripe webhook idempotency:

insert ... on conflict do nothing returning event_id — if RETURNING is empty, the function returns 200 before any side effect. Check-then-insert is racy under Stripe's at-least-once delivery and is documented as the explicit anti-pattern.

Live policy viewer at /docs/security/policies:

Reads pg_policies on the deployed database via a security_invoker view. The page proves the deployed policies are exactly what the docs claim — no static snapshot can drift. Pair with /docs/security/test-results which renders the captured 74/74 green pgtap output from CI.

Stack:

  • Next.js 16 + React 19 + Tailwind 4 + TypeScript strict with noUncheckedIndexedAccess
  • Supabase Cloud (Postgres 17 + Auth + Edge Functions + Realtime + pg_cron)
  • Stripe Checkout + Customer Portal in test mode with insert-first webhook idempotency
  • EC2 + Apache (vhost with ServerAlias for both subdomains) + PM2 + Let's Encrypt
  • Standalone Next.js output, rsync deploys, prod X-Forwarded-Host via ProxyAddHeaders

Quality:

74 pgtap tests, 19 Vitest (units + service-role firewall scan), 5 Playwright public-routes smoke. CI runs all three on every push and PR via GitHub Actions. Forkable template — ./scripts/postclone.sh is idempotent and takes a forker from npm install to green pgtap in under 5 minutes warm cache. Exits non-zero on any red test so a forker cannot accidentally ship a misconfigured deployment.

What it proves:

The same person hand-rolled the data model, wrote 22 RLS policies with hardened security_definer helpers, built the 74-test hostile pgtap suite, scaffolded the Next.js app + docs site with a live policy viewer, implemented HS256 impersonation JWT minting with doubly-logged audits, wired Stripe Checkout / Customer Portal / insert-first webhook idempotency, and deployed all of it to EC2 + Apache + PM2 + Let's Encrypt. The case for hiring me to build a Supabase multi-tenant SaaS correctly the first time, or to audit one that already leaked.

Results

  • 74 pgtap policy tests against a 5×8 hostile fixture — cross-tenant reads, writes, joins, CTEs, UNIONs, aggregates all return zero rows

  • Append-only audit at the Postgres grant level — cb_audit_writer role has INSERT-only and no other grants

  • Doubly-logged impersonation via a 60-min HS256 JWT — every audit row carries both actor_id and impersonator_id, pulled from JWT claims

  • Insert-first Stripe webhook idempotency — on conflict do nothing returning gates every side effect

  • Live policy viewer reads pg_policies on the deployed DB so the docs cannot drift from reality

Gallery

Cinderblock screenshot 2
Cinderblock screenshot 3
Cinderblock screenshot 4

Interested in working together?

Let's discuss how I can help with your project

Get in Touch