Freelancers, agencies, and consultants aren't one-tenant users. A graphic designer belongs to their own business, their client's brand account, and three contractor teams. Velocity X assumes this: the same user, multiple orgs, one active scope at any moment. The org-switcher dropdown in the dashboard top-right isn't a nice-to-have—it's the product.
A naive approach: create a separate account for each org-membership. This explodes the auth surface and fragments the user experience. Better: one user, many org memberships, and a mechanism that switches which org's data they see without logging out.
The Multi-Org Problem
Without a tenant-switcher pattern, you end up with three broken states:
**State 1: App doesn't know about multi-org.** You hardcode `org_id` into the user's auth metadata at signup. When a consultant joins a second org, you either (a) delete the old org_id and break their primary business, or (b) ignore the new org entirely.
**State 2: Frontend pretends multi-org exists but the database doesn't enforce it.** The dropdown switches a local state variable. But your RLS policies don't read a "currently selected org"—they read the JWT claim that was burned into the token at login. So a user can click the dropdown, see client B's data in the UI, but when they try to read the database, they still see client A's rows. Data loads wrong, UI lies, users get confused.
**State 3: You refresh the JWT every time the dropdown changes.** This works but wakes the auth service on every switch, adds latency, and smells like a hack. You're forcing a mini-re-auth for something that should be a lightweight state change.
The Velocity X Pattern: Active Org via JWT Refresh
Velocity X does this: store all org memberships in `team_members`, but keep only the active org ID in the JWT. When the user switches orgs, you immediately refresh the session JWT to update that claim. It's one API call, no re-login, and the database instantly sees the new scope.
Schema:
{`create table organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamp default now()
);
create table team_members (
id uuid primary key default gen_random_uuid(),
org_id uuid references organizations(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text default 'member',
created_at timestamp default now(),
unique(org_id, user_id)
);
create index on team_members(user_id);
create index on team_members(org_id, user_id);`}
The key: team_members rows document every org membership. But the JWT claim holds only one: the active org. RLS policies read that active org from the JWT. When the user switches, refresh the JWT to swap the claim.
RLS That Reads Active Org
The policy pattern is identical to single-tenant, except the claim is scoped by user action:
{`alter table organizations enable row level security;
create policy "users see their active org" on organizations
for select
using (
id = (auth.jwt() ->> 'org_id')::uuid
);
alter table team_members enable row level security;
create policy "see active org members" on team_members
for select
using (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
-- Same for jobs, projects, tasks, etc.
-- Always: org_id matches the JWT claim
alter table jobs enable row level security;
create policy "see active org jobs" on jobs
for select
using (
org_id = (auth.jwt() ->> 'org_id')::uuid
);
create policy "write to active org jobs" on jobs
for insert
with check (
org_id = (auth.jwt() ->> 'org_id')::uuid
);`}
RLS doesn't need to know about the user's other memberships. It only cares: does this row belong to the org ID in the JWT? If yes, allow the query. If no, filter it out.
Switching Orgs: The Frontend + Backend Dance
When the user clicks the org-switcher dropdown, the frontend calls an Edge Function:
{`// POST /functions/v1/switch-org
export default async (req: Request) => {
const { org_id } = await req.json();
const user = await getUser(); // From Authorization header
// Verify the user is actually a member of this org
const { data: membership, error } = await supabase
.from('team_members')
.select('role')
.eq('org_id', org_id)
.eq('user_id', user.id)
.single();
if (!membership) {
return new Response(
JSON.stringify({ error: 'Not a member of that org' }),
{ status: 403 }
);
}
// Refresh the session with the new org_id in the JWT
const { data, error: refreshError } = await supabase.auth.refreshSession();
// Update auth metadata to set active_org_id
await supabase.auth.updateUser({
data: { active_org_id: org_id, role: membership.role }
});
return new Response(
JSON.stringify({
session: data.session,
org_id,
role: membership.role
}),
{ status: 200 }
);
};`}
The Edge Function does three things: (1) verifies the user belongs to that org by checking `team_members`, (2) refreshes the session so Supabase updates the JWT with the new `org_id` claim, and (3) returns the fresh session to the frontend. The frontend stores the new JWT and RLS policies instantly start filtering by the new org.
On the React side:
{`const [orgs, setOrgs] = useState([]);
const [activeOrgId, setActiveOrgId] = useState(null);
useEffect(() => {
// Fetch all orgs the user is a member of
supabase
.from('team_members')
.select('organization:org_id (id, name)')
.eq('user_id', user.id)
.then(({ data }) => {
setOrgs(data.map(m => m.organization));
});
}, []);
const handleSwitchOrg = async (orgId) => {
const res = await fetch('/functions/v1/switch-org', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ org_id: orgId })
});
const { session } = await res.json();
await supabase.auth.setSession(session);
setActiveOrgId(orgId);
// UI re-renders with new JWT, RLS fetches new data
};`}
The switch is instant from the user's perspective: click dropdown, org changes, data refreshes. No page reload, no login screen.
The Catch: JWT Claim Staleness
Supabase JWTs have an expiry—usually 1 hour. If a user switches orgs and then their token expires, the refresh will burn in the old active org. The user logs back in, and they're back in their first org, not the one they switched to.
The workaround: store the preferred active org in the user's metadata, and a Postgres trigger that syncs it to the JWT on every login:
{`-- On auth.users, add a preferred_org_id field
alter table auth.users
add column preferred_org_id uuid;
-- Trigger: when a user logs in, set org_id to preferred_org_id
create or replace function sync_preferred_org_to_jwt()
returns trigger as \$\$
begin
-- Update raw_user_meta_data to include org_id
new.raw_user_meta_data :=
jsonb_set(new.raw_user_meta_data, '{org_id}',
to_jsonb((select preferred_org_id from auth.users where id = new.id)));
return new;
end;
\$\$ language plpgsql security definer;
create trigger sync_org_on_jwt_refresh
before update on auth.users
for each row execute function sync_preferred_org_to_jwt();`}
Now when the user logs in again, their JWT automatically includes the org they were last in. Switching orgs updates `preferred_org_id`, which gets picked up on the next token refresh.
Why Not an Array of Org IDs?
An alternative: store all org IDs in the JWT claim as an array, {"org_ids": ["42", "99"]}, and write RLS policies that check array membership. This avoids the refresh dance. But it trades off precision for simplicity. With an array claim, policies are less strict—you need operators like ? org_id::text to check membership, and mistakes are easier. With a single active org, policies are obvious: org_id = (auth.jwt() ->> 'org_id')::uuid. At scale, obvious is safer.
The Multi-Org Future
Velocity X's approach scales because it separates the concepts: membership (stored in the database and persisted) from scope (stored in the JWT and user-controlled). A user can belong to 50 orgs and switch between them without a backend restart. Each switch is a lightweight token refresh. RLS remains simple and auditable.
If you're building SaaS for freelancers, agencies, or consultants, multi-org isn't a luxury feature—it's table stakes. The org-switcher dropdown is the entry point. Get the JWT claim right, get RLS to enforce the scope, and your users won't feel like they're living in five different apps. They'll feel like they're using one app with a context switch.
For the full RLS deep-dive—edge cases, performance tuning, and the policies that hold up in production—check out our multi-tenant SaaS RLS guide. And if you're ready to build multi-org into your product, we can help you architect it. See pricing.