The Architecture Overview

All tenants share the same Supabase database. Isolation is enforced by Row-Level Security (RLS) policies, PostgreSQL-level rules that filter every query based on the authenticated user's JWT claims.

When a user signs in, their JWT contains a workspace_id claim. Every RLS policy checks this claim against a workspace_id column in the table. Users literally cannot query data from other workspaces, the database enforces it, not the application code.

Setting Up the Workspace Structure

Tables you need:

1. workspaces (id, name, plan, created_at) 2. workspace_members (id, workspace_id, user_id, role, created_at) 3. All your business tables (with workspace_id column)

When a user signs up, create a workspace and a workspace_members record in a Supabase database function. The workspace_id goes into the user's JWT via a custom claim hook.

Writing RLS Policies

For every tenant-scoped table, enable RLS and add these policies:

SELECT policy:
CREATE POLICY "users can read own workspace data"
ON your_table FOR SELECT
USING (workspace_id = (auth.jwt() ->> 'workspace_id')::bigint);
INSERT policy:
CREATE POLICY "users can insert to own workspace"
ON your_table FOR INSERT
WITH CHECK (workspace_id = (auth.jwt() ->> 'workspace_id')::bigint);

This enforces isolation at the database level, no matter what your frontend or API does.

Role-Based Access Control

Within a workspace, different users have different permissions (owner, admin, member). Store this in workspace_members.role.

For data-level RBAC, use Supabase functions in your RLS policies:

CREATE FUNCTION get_user_role() RETURNS text AS $$
  SELECT role FROM workspace_members
  WHERE user_id = auth.uid()
  AND workspace_id = (auth.jwt() ->> 'workspace_id')::bigint
$$ LANGUAGE sql STABLE;

Then in policies: USING (get_user_role() IN ('admin', 'owner'))

Wiring It Up in WeWeb

In WeWeb, configure your Supabase data source with the authenticated user's token. WeWeb passes the JWT automatically with every request.

For your app's navigation and UI, read the role from a global variable (populated from the workspace_members table on login). Show/hide menu items, buttons, and entire sections based on role, this is presentation-only, the real security is in RLS.

Never hard-code tenant IDs in WeWeb. All data filtering should come from the authenticated user's JWT, Supabase handles the rest.

Writing RLS Policies That Actually Work: Common Mistakes

The most common RLS mistake: writing the policy but forgetting to enable RLS on the table. In Supabase, you must run ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;, without this, the policies are created but do nothing. Verify every table is RLS-enabled in the Supabase dashboard under Table Editor > Policies.

The second common mistake: missing the WITH CHECK clause on INSERT and UPDATE policies. The USING clause filters what users can read. The WITH CHECK clause validates what they can write. If you write a policy with only USING and omit WITH CHECK on an UPDATE policy, users can potentially update rows to point at a different workspace_id, a silent tenant isolation bypass. Always write both clauses.

The third mistake: service role key exposure. Supabase has two keys: the anon key (respects RLS) and the service role key (bypasses RLS entirely). Never expose the service role key in your WeWeb frontend or in any client-side code. It belongs only in server-side code (Xano, Edge Functions) for operations that legitimately need to bypass RLS, like admin tasks or webhook processing.

Tenant Onboarding Flow: The Complete Implementation

A clean tenant onboarding flow is critical, it sets the workspace up correctly and gates the user into their scoped experience. Here is the complete flow we implement on every multi-tenant WeWeb project.

Step 1: User clicks "Sign Up" and submits email + password. Supabase Auth creates the user account. Step 2: A Supabase database trigger (AFTER INSERT ON auth.users) fires a function that: creates a workspaces row with a default plan, creates a workspace_members row linking the user as owner, and sets the workspace_id custom JWT claim via Supabase's auth hook. Step 3: The user is redirected to an onboarding page in WeWeb where they set their workspace name, invite team members, and complete setup. Step 4: All subsequent page loads use a JWT that contains workspace_id, every Supabase query is automatically tenant-scoped.

For invited users (not the workspace creator), the flow differs: the owner generates an invitation token (stored in a workspace_invitations table), the invitee clicks the link and creates an account, and a database trigger creates their workspace_members record with the invited role. We always expire invitation tokens after 72 hours and validate they haven't been used before processing them.

Custom Domains per Tenant

Custom domains are a premium feature in many SaaS products, allowing enterprise clients to access the app at app.clientcompany.com instead of app.yoursaas.com. Implementing this in a WeWeb + Supabase stack requires a few coordinated pieces.

On the infrastructure side, WeWeb can be deployed to Vercel or Netlify, both of which support custom domains via their APIs. When a tenant adds a custom domain, your Xano backend calls the Vercel API to register the domain and provision an SSL certificate. The tenant then adds a CNAME record pointing their subdomain to your Vercel deployment.

On the authentication side, Supabase Auth needs to be configured to allow the custom domain as a redirect URL after login. Add the tenant's domain to Supabase's allowed redirect URLs list via the Supabase Management API (again triggered from Xano). WeWeb's auth redirect URL should read the current hostname dynamically rather than having a hardcoded callback URL, use JavaScript to determine the current origin at runtime.

For the WeWeb app itself, detect the custom domain on load and look up the corresponding workspace_id in a custom_domains table. This workspace_id is used to scope the login page and any public-facing content before authentication. In our projects, we've implemented custom domains for two enterprise clients, the total implementation time is 2–3 days of WeWeb and Xano configuration. Need help? Our WeWeb developers specialise in multi-tenant SaaS architecture.

Billing Isolation: Connecting Stripe to Your Tenant Model

Each tenant must have exactly one Stripe customer record, linked to their workspace. Create the Stripe customer when the workspace is created (in your Xano onboarding webhook or Supabase database trigger) and store the stripe_customer_id on the workspaces table. This linkage is the foundation of all billing operations.

For subscription management, create a workspace_subscriptions table: workspace_id, stripe_subscription_id, plan (free/pro/enterprise), status (trialing/active/past_due/cancelled), current_period_end. Keep this table updated via Stripe webhooks in Xano. Your RLS policies can reference this table, for example, restrict certain features to workspaces where status = 'active'.

One important isolation concern: Stripe webhooks are processed server-side in Xano, but the subscription data needs to be reflected in the UI. We use Supabase Realtime to push subscription status changes to the active WeWeb session. When Xano processes a successful payment webhook and updates workspace_subscriptions, the WeWeb frontend receives the Realtime change event and updates the UI without requiring a page reload. This gives users instant feedback when their payment is processed, critical for upgrade flows where the user is waiting on screen.