Get a Quote
Development

How SiteLive Keeps Data Safe: Roles + Row Level Security on Supabase

How SiteLive isolates every customer's data using Postgres Row Level Security plus a small, explicit role model — and why bypassing RLS never means bypassing authorization.

Webnyxa Technologies

5 views

How SiteLive Keeps Data Safe: Roles + Row Level Security on Supabase

The problem: one database, thousands of tenants

SiteLive is a multi-tenant platform. Thousands of independent business owners build and run websites, stores, review widgets, and lead inboxes on shared infrastructure backed by a single Supabase (Postgres) database.

The hard security question in any multi-tenant SaaS is simple to state and easy to get wrong: how do you guarantee that Customer A can never see Customer B's data?

Our answer has two layers: Row Level Security (RLS) in Postgres, where the database itself enforces "you can only read/write your own rows," and an explicit role and authorization model for the small number of privileged paths (admins, background jobs, public form submissions) that must operate above a single tenant.

The actors in the system

Rather than a sprawling permissions matrix, SiteLive has a small, well-defined set of actor types.

Actor

Who they are

How it's granted

What they can do

Site owner

The paying/free customer

Default on signup

Manage only their own sites, builder, billing, and lead inbox

Super admin

Internal full admin

A profile flag, an email allow-list, or an admin role field

Everything: impersonation, suspend/restore, billing, admin tools, deletes

CRM staff

Limited internal team

Admin role field (only a super admin can set it)

Platform CRM + read-only admin panel; cannot delete

Impersonation session

A super admin acting as a customer (for support)

Short-lived signed cookie

Sees the customer's data scope, with the admin's identity preserved for audit

Anonymous visitor

Someone visiting a generated customer site

No login

Only the public, scoped APIs (submit a form, place an order, read approved reviews)

Roles are columns on the user profile table, not custom JWT claims. This keeps role changes instant and auditable.

-- Super admin flag
ALTER TABLE public.profiles ADD COLUMN is_super_admin boolean NOT NULL DEFAULT false;

-- Granular admin role: 'super_admin' | 'crm_staff' | NULL (normal user)
ALTER TABLE public.profiles ADD COLUMN admin_role text DEFAULT NULL;

The single source of truth for "who is this and what can they do" is a shared actor context:

type ActorContext = {
  actorUserId: string;       // the real logged-in user
  effectiveUserId: string;   // the user whose data we're acting on (differs when impersonating)
  isSuperAdmin: boolean;
  isAdmin: boolean;          // super_admin OR crm_staff
  canDelete: boolean;        // super_admin only
  impersonatingUserId: string | null;
};

Layer 1: Row Level Security does the heavy lifting

Every tenant-owned table has RLS enabled, and the policies bind data to the logged-in user via auth.uid(). For directly-owned tables it's a straight comparison; for child tables it's a subquery back to the owned sites.

-- A user can only see their own (non-deleted) sites
CREATE POLICY "Users can view their own active sites"
  ON sites FOR SELECT TO authenticated
  USING (auth.uid() = user_id AND deleted_at IS NULL);

-- Lead/enquiry submissions are visible only to the owner of the site they belong to
CREATE POLICY "Site owners can read submissions"
  ON public.form_submissions FOR SELECT
  USING (site_id IN (SELECT id FROM public.sites WHERE user_id = auth.uid()));

-- Store products are managed only by the site owner
CREATE POLICY "Site owners can manage products"
  ON public.store_products FOR ALL
  USING (site_id IN (SELECT id FROM public.sites WHERE user_id = auth.uid()));

The important property: even if we made a mistake in application code and forgot a WHERE user_id = ... filter, Postgres would still refuse to return another tenant's rows. Security doesn't depend on us being perfect in every query.

A few deliberate patterns fall out of this:

  • Public reads of published data only — for example, blog posts are world-readable only when their status is "published."

  • No public write policies — tables like form submissions, store orders, and site reviews have no insert policy for anonymous users. All writes go through validated server routes.

  • Server-only tables — sensitive internal tables (admin sessions, CRM activity timelines, audit logs) have RLS enabled with no user policies at all, so only the service role can touch them.

Layer 2: two connection types, on purpose

SiteLive uses two kinds of Supabase client, and the distinction is the crux of the model.

Client

Credentials

RLS

Where it's used

User client

Anon key + the user's session

Enforced

Normal dashboard reads/writes

Admin client

Service-role key

Bypassed

Public APIs, admin tools, background jobs, impersonation

The service-role client is powerful, since it ignores RLS, so it is never shipped to the browser and is only used on the server.

/**
 * Admin Supabase client using the service role key.
 * This bypasses RLS — never expose to the client.
 */
export function createAdminClient() {
  return createClient<Database>(url, serviceKey, { auth: { persistSession: false } });
}

Because the admin client bypasses RLS, any route that uses it re-applies the ownership check in application code. That's the defense-in-depth seam:

// Even with the admin client, non-super-admins are still scoped to their own sites
let siteQuery = admin.from("sites").select("id, user_id").eq("id", siteId);
if (!actor.isSuperAdmin) siteQuery = siteQuery.eq("user_id", actor.effectiveUserId);

Impersonation: actor vs. effective user

When support needs to "see what the customer sees," a super admin can impersonate a user. The design keeps two identities separate: the actor, who is the real admin (their session never changes, so audit logs always show who actually did something), and the effective user, who is the customer whose data is in scope.

Starting impersonation sets a short-lived, signed, httpOnly cookie (capped at a few hours), and it's only honored if the requester is genuinely a super admin and the token matches the logged-in user. Because RLS still keys on the admin's own identity, impersonated routes switch to the admin client and scope data by the effective user instead. Sensitive side effects are suppressed — for example, an admin's location never overwrites the customer's billing region during impersonation.

The two CRMs (and why "lead" means two things)

SiteLive has two distinct CRMs, and they sit on opposite sides of the security boundary.

The site-owner leads CRM covers the enquiries a customer's website visitors submit. It's stored with pipeline columns (new, contacted, trial booked, converted, lost), and it's secured by RLS: an owner sees only leads for sites they own.

The platform CRM covers platform signups our internal team triages and pitches. It's stored on the profile table plus an activity timeline, secured by application-layer role checks, and the activity timeline is service-role-only:

CREATE POLICY "Service role full access on lead_activity"
  ON public.lead_activity FOR ALL
  USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');

CRM staff can work the platform CRM but get a read-only admin panel and cannot perform destructive actions — those require super-admin-only delete permissions.

The public surface: generated sites hold no secrets

Every website SiteLive generates is a static export. It has no database credentials. When a visitor submits a contact form, places an order, or reads reviews, the site calls a small set of public API routes that are hardened without any logged-in user:

  • Site scoping — the site ID is validated on every request.

  • Feature/existence checks — for example, the store must be enabled before an order is accepted.

  • Service role plus server-side business rules — order line items and totals are always recomputed on the server from current prices; client-sent amounts are ignored.

  • Rate limiting — per-IP windows on forms, reviews, and orders.

  • Payload caps and honeypots — small size limits and hidden fields to shed bot traffic.

  • PII minimization — the public reviews API never returns reviewer emails or IP addresses.

Defense in depth, everywhere else

Beyond RLS and role checks, the platform layers additional protections: upload sanitization with magic-byte verification and script-stripping so a malicious file can't smuggle executable content; hardened serving with strict content-type and framing headers on served assets; signed webhook verification for payment and messaging providers before anything is processed; a billing region lock that pins a user's currency and gateway to prevent price manipulation; and an audit trail with soft deletes, so actions are logged with an actor and deletes are reversible.

Takeaway

The core idea is layered trust. Postgres RLS is the floor — the database refuses cross-tenant access even if the app is buggy. The role model is the ceiling — a small, explicit set of actors with escalating capability. The service role is the pressure valve for admins, jobs, and public traffic, always paired with an application-layer check so bypassing RLS never means bypassing authorization.

That combination lets SiteLive move fast on features while keeping each customer's data provably isolated.

Share

Comments

No comments yet. Be the first to share a thought.

Leave a comment

Related posts