HomeGuidesRemoved team member still has access in Supabase? Fix it
Guide
Removed team member still has access in Supabase? Fix it
Cut off a removed teammate in Supabase for good. Fix stale JWT claims, old invite and share links, signed URLs, Realtime and sessions, then test each.
Check your Supabase app free with LeakCheck. Sign in with Google to keep scanning. Founding 50: 33% off Pro for life.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
You built team workspaces with Lovable, Bolt, or Cursor. Owners can invite people, members see the team's projects, and there is a Remove button. Someone leaves on bad terms, you click Remove, their name disappears from the member list. Then they open the app from the tab they never closed, and everything is still there. Or they open the share link they sent themselves last month. Or their phone keeps getting live updates.
Removing a member is a row delete. Revoking access is every path that member used to reach your data: the database policy, the token in their browser, the links they hold, the files they downloaded, the open WebSocket, and any server route that uses the secret key. AI tools build the first one and skip the rest. This guide walks through each path in Supabase, the fix for each, and a test you can run in a few minutes. For the rest of your policies, keep the Supabase RLS checklist open next to this page.
Checked and tested on October 21, 2026 against the Supabase RLS, sessions, user management, Realtime authorization, Storage, and Auth reference docs, the Supabase Auth and Realtime source on GitHub, and the Clerk, Firebase, OWASP, and PostgreSQL pages linked below. We ran every SQL block on this page on PostgreSQL 17.11 behind PostgREST 16.5 (the API layer Supabase uses) with @supabase/supabase-js 2.118.0 and Supabase-style roles, and type-checked the TypeScript with Next.js 16.4.2 and TypeScript 7.0.2. VibeLocked is independent and not affiliated with Supabase, Clerk, or Google.
The short version
- Policies should read membership from a table at query time, not from the JWT. Team ids in
app_metadataor a custom claim stay in the token until it refreshes, up to 1 hour by default. - Use a
security definerhelper in a private schema for the membership lookup, soteam_memberspolicies do not recurse. - Invite links: a random token, stored hashed, bound to the invited email, single use, expiring, revocable, and dead if the inviter is no longer an admin.
- Share links: the same, checked on every view, and dead as soon as the person who shared it leaves.
- Storage signed URLs cannot be revoked. Sign them on the server after a membership check, for seconds, not days.
- Realtime caches access per connection until a new JWT arrives. Keep JWT expiry short and use membership lookups in your policies.
- Signing out does not kill access tokens already issued. For a full offboarding, delete the user's sessions and keep the JWT expiry short.
- Every route that uses the secret key must check membership itself. The key skips RLS.
- Test it: remove a member, then retry every path with their old token and links.
Why removing the row is not enough
A Supabase session is two tokens. The access token is a signed JWT that your database trusts on its own, without asking Auth. The refresh token lives longer and mints new access tokens. The sessions guide says access tokens "are designed to be short lived, usually between 5 minutes and 1 hour," and that "most applications should use the default expiration time of 1 hour."
Anything copied into that JWT is a snapshot. The signOut reference is blunt about the consequence: "There is no way to revoke a user's access token jwt until it expires. It is recommended to set a shorter expiry on the jwt for this reason." OWASP's Broken Access Control entry lists "abusing JWT invalidation" as a classic form of the bug and says stateless JWTs "should be short-lived to minimize the window of opportunity."
So the real question for each access path is: does this check read live data, or something the user already holds?
| Access path | What usually checks it | After you remove the member |
|---|---|---|
| Table reads and writes (RLS) | A team claim in the JWT | Works until the token refreshes |
| Table reads and writes (RLS) | A lookup in team_members |
Ends on the next request |
Share link /share/<id> |
Nothing, or a secret key route | Works forever |
| Invite link | A token that never expires | Still lets them, or anyone, rejoin |
| Storage signed URL | Its own signature and expiry | Works until expiresIn runs out |
| Realtime private channel | Policies cached for the connection | Streams until a new JWT or expiry |
| Server route with the secret key | Whatever the route checks | Works unless the route checks membership |
| Refresh token | auth.sessions |
Keeps minting tokens until you sign them out |
| Client-side cache | Your React state | Shows old data until reload |
Bug 1: the policy trusts a stale claim
A common way to dodge RLS recursion is to copy the user's team ids into app_metadata (or add them with a custom access token hook) and read them in the policy. Supabase's own RLS guide shows this pattern, then warns: "Keep in mind that a JWT is not always up-to-date. In the team policy example, even if you remove a user from a team and update the app_metadata field, that will not be reflected using auth.jwt() until the user's JWT is refreshed." The custom access token hook has the same property, because it "runs before a token is issued," not on every request.
Here is the table layout we tested, which is what AI tools usually generate for teams:
create table public.teams (
id uuid primary key default gen_random_uuid(),
name text not null
);
create table public.team_members (
team_id uuid not null references public.teams (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
role text not null default 'member' check (role in ('owner', 'admin', 'member')),
primary key (team_id, user_id)
);
create table public.documents (
id uuid primary key default gen_random_uuid(),
team_id uuid not null references public.teams (id) on delete cascade,
title text not null,
body text
);
alter table public.teams enable row level security;
alter table public.team_members enable row level security;
alter table public.documents enable row level security;
Those on delete cascade keys mean that whoever can delete a teams row also deletes every document and membership row in that team, and RLS on those tables does not stop it. If the client can delete teams, read Supabase ON DELETE CASCADE skips RLS first.
And the policy that trusts the token:
-- The pattern to avoid: team ids copied into app_metadata, read from the JWT.
create policy "members read documents (claim)"
on public.documents for select to authenticated
using (
team_id in (
select jsonb_array_elements_text(auth.jwt() -> 'app_metadata' -> 'teams')::uuid
)
);
What we saw. We signed a JWT for Bob the way Supabase would, with app_metadata.teams listing the Acme team, and read documents through PostgREST: 2 rows. Then we deleted Bob's team_members row and cleared teams in his app_metadata, exactly what a Remove button does. Bob's next request with the same token still returned 2 rows. Only a newly minted token, with the updated app_metadata, returned 0. In production that gap is the JWT expiry: up to an hour on the default setting, and longer if someone raised it.
Shortening the JWT expiry narrows the window but does not close it, and Supabase advises against going below 5 minutes because of load and clock skew. The real fix is to stop asking the token.
The fix: look up membership on every query
Put the lookup in a security definer function in a schema the API does not expose. It reads team_members as its owner, so the team_members policies do not run inside it and nothing recurses. That is the pattern from our infinite recursion guide, and the one Supabase documents under security definer functions.
drop policy if exists "members read documents (claim)" on public.documents;
create schema if not exists private;
-- Team ids the current user belongs to, read live from team_members.
create or replace function private.user_team_ids()
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
select m.team_id
from public.team_members m
where m.user_id = (select auth.uid())
$$;
-- Team ids where the current user is an owner or admin.
create or replace function private.user_admin_team_ids()
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
select m.team_id
from public.team_members m
where m.user_id = (select auth.uid())
and m.role in ('owner', 'admin')
$$;
revoke execute on function private.user_team_ids() from public, anon;
revoke execute on function private.user_admin_team_ids() from public, anon;
grant usage on schema private to authenticated;
grant execute on function private.user_team_ids() to authenticated;
grant execute on function private.user_admin_team_ids() to authenticated;
create policy "members read their teams"
on public.teams for select to authenticated
using (id in (select private.user_team_ids()));
create policy "members see co-members"
on public.team_members for select to authenticated
using (team_id in (select private.user_team_ids()));
create policy "admins remove members, anyone can leave"
on public.team_members for delete to authenticated
using (
team_id in (select private.user_admin_team_ids())
or user_id = (select auth.uid())
);
create policy "members read documents"
on public.documents for select to authenticated
using (team_id in (select private.user_team_ids()));
create policy "members write documents"
on public.documents for insert to authenticated
with check (team_id in (select private.user_team_ids()));
create policy "members edit documents"
on public.documents for update to authenticated
using (team_id in (select private.user_team_ids()))
with check (team_id in (select private.user_team_ids()));
The details that matter:
privateschema. Functions in an exposed schema are callable at/rest/v1/rpc/<name>. In our test, callingprivate.user_team_idsover the API failed withPGRST106(schema not exposed). Supabase's hardening guide explains which schemas are exposed.set search_path = ''with schema-qualified names, per the PostgreSQL CREATE FUNCTION advice on writing security definer functions safely.- No user id parameter. The function filters by
(select auth.uid())itself, so nobody can ask about another user. - Wrapped in
(select ...)in the policy so Postgres runs it once per statement, as the Supabase RLS performance guide recommends.
What we saw. Same setup, same Bob, same unexpired token that still listed the team in app_metadata. The owner removed Bob through the API (the delete policy allowed it; Bob trying to remove the owner deleted nothing). Bob's very next request returned 0 documents, 0 teams, and 0 members, and an update to a team document changed 0 rows. A user on another team only ever saw their own team's document, and anon saw nothing.
If you are already on claims and cannot change every policy at once, keep the claim for UI hints (which team switcher to show) and move the policies that guard data to the lookup first. A claim is fine for display. It is not fine for authorization.
Clerk Organizations and Firebase
Clerk. Clerk puts the active organization in the session token as the o claim (id, slug, role, permissions), per its session token reference, and the server-side has() helper reads it from there. The difference is lifetime: Clerk's How Clerk works page says the session token "expires after 60 seconds" and the SDK refreshes it about every minute. So after deleteOrganizationMembership(), a removed member's claim lasts at most about a minute. If you use Clerk with Supabase through third-party auth, Supabase's example policies read org_id or o.id from the JWT, which inherits the same short window. That is usually acceptable. If it is not, or if you raised the token lifetime in a JWT template, mirror memberships into a table and use the lookup pattern above. To end the person's session too, call revokeSession() for each of their sessions.
Firebase. Custom claims set with setCustomUserClaims "will propagate to the user's ID token the next time a new one is issued," per the custom claims docs, and ID tokens "last for an hour" per Manage user sessions. A Firestore rule like request.auth.token.teams has the same stale window as Bug 1. Instead, check a membership document with exists() or get(), which the rules conditions docs describe as evaluating "incoming requests against other documents in the database":
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /teams/{teamId}/docs/{docId} {
allow read, write: if request.auth != null
&& exists(/databases/$(database)/documents/teams/$(teamId)/members/$(request.auth.uid));
}
}
}
Then make sure only team admins can write to members. For a full sign out, revokeRefreshTokens(uid) stops new ID tokens, and your server should verify tokens with verifyIdToken(token, true) so revoked ones fail with auth/id-token-revoked. Both are in the sessions doc above.
Bug 2: invite links that never die
The usual AI-built invite is a row with a random id, emailed as /invite?id=..., that adds whoever opens it to the team. It never expires, works more than once, works for whoever it was forwarded to, and still works after the admin who sent it was removed. OWASP's Forgot Password Cheat Sheet lists what any emailed token needs: "Randomly generated using a cryptographically safe algorithm," "Sufficiently long to protect against brute-force attacks," "Stored securely," and "Single use and expire after an appropriate period." Invites are the same kind of token.
create table public.team_invites (
id uuid primary key default gen_random_uuid(),
team_id uuid not null references public.teams (id) on delete cascade,
email text not null,
role text not null default 'member' check (role in ('admin', 'member')),
token_hash text not null unique, -- sha256 hex of the token, never the token
created_by uuid not null references auth.users (id) on delete cascade,
expires_at timestamptz not null default now() + interval '7 days',
accepted_at timestamptz,
accepted_by uuid references auth.users (id),
revoked_at timestamptz
);
alter table public.team_invites enable row level security;
create policy "admins see their team's invites"
on public.team_invites for select to authenticated
using (team_id in (select private.user_admin_team_ids()));
create policy "admins invite as themselves"
on public.team_invites for insert to authenticated
with check (
team_id in (select private.user_admin_team_ids())
and created_by = (select auth.uid())
);
-- Admins can revoke any pending invite for their team; revoked_at is the only
-- column anyone can change from the API.
create policy "admins revoke their team's invites"
on public.team_invites for update to authenticated
using (team_id in (select private.user_admin_team_ids()));
revoke update on public.team_invites from authenticated;
grant update (revoked_at) on public.team_invites to authenticated;
-- Called by the invited user, signed in, with the raw token from the link.
create or replace function public.accept_team_invite(p_token text)
returns uuid
language plpgsql
volatile
security definer
set search_path = ''
as $$
declare
v_team uuid;
v_role text;
begin
if (select auth.uid()) is null then
raise exception 'sign in first' using errcode = '42501';
end if;
-- One statement: find, check and burn the invite, so two clicks cannot both win.
update public.team_invites i
set accepted_at = now(), accepted_by = (select auth.uid())
where i.token_hash = encode(sha256(convert_to(p_token, 'UTF8')), 'hex')
and i.accepted_at is null
and i.revoked_at is null
and i.expires_at > now()
and lower(i.email) = lower((select auth.jwt()) ->> 'email')
and exists ( -- the inviter is still an admin
select 1 from public.team_members m
where m.team_id = i.team_id
and m.user_id = i.created_by
and m.role in ('owner', 'admin'))
returning i.team_id, i.role into v_team, v_role;
if v_team is null then
raise exception 'invite is invalid, used, revoked, or expired' using errcode = '22023';
end if;
insert into public.team_members (team_id, user_id, role)
values (v_team, (select auth.uid()), v_role)
on conflict (team_id, user_id) do nothing;
return v_team;
end;
$$;
revoke execute on function public.accept_team_invite(text) from public, anon;
grant execute on function public.accept_team_invite(text) to authenticated;
The server creates the token, stores only its hash, and emails the raw token once. The admin's own client does the insert, so RLS checks they are an admin of that team:
import { hashToken, newToken } from '@/lib/share-links';
const token = newToken();
const { error } = await supabase.from('team_invites').insert({
team_id: teamId,
email: inviteeEmail,
token_hash: hashToken(token),
created_by: user.id,
});
// Email https://your-app.com/invite?token=<token>. Never store the token itself.
The invited person signs in, and your invite page calls supabase.rpc('accept_team_invite', { p_token: token }). accept_team_invite is in public on purpose, because it is an API endpoint, and it is the only thing that can add a member besides your server. EXECUTE is revoked from anon, so signed-out visitors cannot call it.
What we saw. Only 64-character hashes were stored. A signed-in user with a different email could not use Carol's link, and anon got 42501. Carol joined with it once; the second try failed. An expired link failed, a revoked link failed, and a link sent by an admin who was removed before Carol clicked it failed. Ten parallel accepts of one fresh link produced exactly one success, because the check and the burn happen in a single UPDATE, the same trick as in our credit race condition guide. Use a verified email for the match: if your project lets people sign up without confirming their email, someone could register the invited address first.
Bug 3: share links that outlive the member
AI tools usually build sharing as /share/<document-id> and serve it from a route that reads the document with the secret key. That is an IDOR waiting to happen if the ids are guessable, and even with UUIDs, the link is a password that cannot be changed. In our test, the naive version still served the document after the member who shared it was removed.
Give share links their own table, with a hash, an expiry, and a revoke switch:
create table public.share_links (
id uuid primary key default gen_random_uuid(),
document_id uuid not null references public.documents (id) on delete cascade,
token_hash text not null unique,
created_by uuid not null references auth.users (id) on delete cascade,
expires_at timestamptz not null default now() + interval '7 days',
revoked_at timestamptz
);
alter table public.share_links enable row level security;
-- No policy for anon: visitors never read this table directly.
create policy "members see their team's links"
on public.share_links for select to authenticated
using (
exists (
select 1 from public.documents d
where d.id = share_links.document_id
and d.team_id in (select private.user_team_ids())
)
);
create policy "members create links as themselves"
on public.share_links for insert to authenticated
with check (
created_by = (select auth.uid())
and exists (
select 1 from public.documents d
where d.id = share_links.document_id
and d.team_id in (select private.user_team_ids())
)
);
-- Any member can revoke a link to a team document, and revoked_at is the only
-- column they can change.
create policy "members revoke their team's links"
on public.share_links for update to authenticated
using (
exists (
select 1 from public.documents d
where d.id = share_links.document_id
and d.team_id in (select private.user_team_ids())
)
);
revoke update on public.share_links from authenticated;
grant update (revoked_at) on public.share_links to authenticated;
The resolver runs on every view, on the server, with the secret key. Because that key skips RLS, the function checks membership itself:
// lib/share-links.ts: server only. Never import this into a client component.
import { createHash, randomBytes } from 'node:crypto';
import type { SupabaseClient } from '@supabase/supabase-js';
export const newToken = () => randomBytes(32).toString('base64url'); // 43 chars, 256 bits
export const hashToken = (token: string) =>
createHash('sha256').update(token, 'utf8').digest('hex');
// Called by a signed-in member with their own (RLS) client. Returns the raw token once.
export async function createShareLink(supabase: SupabaseClient, documentId: string, userId: string, days = 7) {
const token = newToken();
const { error } = await supabase.from('share_links').insert({
document_id: documentId,
token_hash: hashToken(token),
created_by: userId,
expires_at: new Date(Date.now() + days * 86_400_000).toISOString(),
});
if (error) throw error;
return token; // put it in the URL, never store it
}
// Called on every view, with the server-only secret key client.
export async function resolveShareLink(admin: SupabaseClient, token: string) {
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
const { data: link } = await admin
.from('share_links')
.select('created_by, documents!inner(team_id, title, body)')
.eq('token_hash', hashToken(token))
.is('revoked_at', null)
.gt('expires_at', new Date().toISOString())
.maybeSingle();
if (!link) return null;
// The secret key skips RLS, so check membership here: the person who
// shared the link must still be on the team, or the link is dead.
const doc = link.documents as unknown as { team_id: string; title: string; body: string };
const { data: member } = await admin
.from('team_members')
.select('user_id')
.eq('team_id', doc.team_id)
.eq('user_id', link.created_by)
.maybeSingle();
if (!member) return null;
return { title: doc.title, body: doc.body };
}
And the page that uses it:
import { notFound } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import { resolveShareLink } from '@/lib/share-links';
export const dynamic = 'force-dynamic'; // check the link on every view, never cache it
// Server only: the secret key never reaches the browser.
const admin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, {
auth: { persistSession: false },
});
export default async function SharedDocument({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const doc = await resolveShareLink(admin, token);
if (!doc) notFound();
return (
<article>
<h1>{doc.title}</h1>
<p>{doc.body}</p>
</article>
);
}
What we saw. A member's link resolved. A user from another team could not create a link to an Acme document (RLS rejected the insert), and anon could not list share_links. When the member was removed, their link returned nothing on the next view, while the naive /share/<id> kept working. The owner revoked a link and the next view failed. Expired, random, and malformed tokens all failed. Trying to change created_by on a link failed with 42501, because revoked_at is the only column members can update, the same column grant approach as in our column security guide.
One catch we found: if the removed member is later added back, their old links work again, because the resolver checks current membership. That is why the removal code below also sets revoked_at on their links. Send share pages with Referrer-Policy: no-referrer too, so the token in the URL is not sent to other sites the page links to.
Bug 4: Storage signed URLs cannot be revoked
A private bucket protected by policies on storage.objects will follow your membership lookup, as long as the policy uses it. Signed URLs do not. Supabase's download docs say: "Signed URLs remain valid until their expiry time regardless of any Auth key changes. If you need to revoke signed URLs, contact Supabase support." The createSignedUrl reference takes expiresIn, "the number of seconds until the signed URL expires."
AI tools tend to create signed URLs with a week or a year of life and save them in a column, so the app does not have to sign again. Every one of those is a key that a removed member keeps. Instead:
- Write Storage policies that check membership with the same helper, for example
(storage.foldername(name))[1]::uuid in (select private.user_team_ids())for a<team-id>/<file>layout. Our Storage bucket guide covers the folder pattern. - Sign URLs on demand, on the server, after the membership check, with
expiresInof 60 to 300 seconds. - Never store a signed URL in the database or send it in an email that could be forwarded.
- Remember that anything a member already downloaded is theirs. Revocation covers future access, not copies.
Bug 5: Realtime keeps streaming
The Realtime authorization docs say client access policies "are cached for the duration of the connection," refreshed only when a client joins a channel or sends a new JWT. Then: "if you revoke a user's access (for example, by removing a row an RLS policy uses to grant it) while they're still connected, they'll keep receiving messages until their JWT expires or a new one is sent." If no new token arrives, the client is disconnected at expiry.
Postgres Changes behave a little differently. In Realtime's apply_rls source, each change is checked against RLS with the subscriber's stored claims, so a policy that does a live membership lookup stops new rows right away, while a claim-based policy keeps sending them until the token changes. Delete events skip that RLS check entirely in the same source, so do not put secrets in primary keys.
What to do: use membership lookups in your realtime.messages policies and table policies, and use private channels as our Realtime authorization guide describes. With lookups in place, a removed member's private channel access ends the next time their client sends a refreshed token, so the longest they can keep listening is one JWT lifetime. If an hour is too long for your app, lower the JWT expiry in the Auth settings, keeping Supabase's advice not to go below 5 minutes.
Bug 6: their sessions keep refreshing
Removing someone from one team should not usually sign them out of your app. They may belong to other teams. But when you offboard an employee or a bad actor completely, you want their sessions gone.
Supabase gives you three tools, with sharp edges (what signOut itself does per scope, and what a ban leaves behind, is tested in Supabase signOut scopes and session revocation):
auth.admin.signOut(jwt, scope)needs the user's own JWT (reference), which your server normally does not have.auth.admin.deleteUser()removes the user, which "cascades toauth.sessionsand invalidates the user's refresh tokens," per Managing user data. The same page warns that a temporary ban "does not revoke existing sessions" (in the Auth server source, a banned user's refresh is rejected, but the sessions stay).- Delete their rows in
auth.sessions.refresh_tokens.session_idreferencesauth.sessionswithon delete cascadein the Auth migrations, so this kills every refresh token. Wrap it in a function only your server can call:
-- Sign a user out everywhere: deletes their sessions, which deletes their refresh tokens.
-- Only your server (secret or service_role key) may call it.
create or replace function public.revoke_user_sessions(p_user_id uuid)
returns integer
language sql
volatile
security definer
set search_path = ''
as $$
with gone as (
delete from auth.sessions s where s.user_id = p_user_id returning 1
)
select count(*)::integer from gone
$$;
revoke execute on function public.revoke_user_sessions(uuid) from public, anon, authenticated;
grant execute on function public.revoke_user_sessions(uuid) to service_role;
None of these touch access tokens already issued. The sessions guide offers the hard guarantee for the rare table that needs it: check that the token's session_id still exists in auth.sessions. As a restrictive policy, it applies on top of the normal ones:
-- Optional, for the most sensitive tables: the JWT's session must still exist.
create or replace function private.session_is_active()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from auth.sessions s
where s.id = ((select auth.jwt()) ->> 'session_id')::uuid
)
$$;
revoke execute on function private.session_is_active() from public, anon;
grant execute on function private.session_is_active() to authenticated;
create policy "require a live session"
on public.documents as restrictive for all to authenticated
using ((select private.session_is_active()));
What we saw. A signed-in user calling revoke_user_sessions got 42501. The server (service role) deleted Bob's one session, and Bob's next request with the same unexpired JWT read 0 documents, while the owner was unaffected. This costs a lookup on every row check, so keep it for payroll, billing, or admin tables, as Supabase suggests. Run supabase.auth.getUser() on the server for sensitive actions too: in the Auth server source, a token whose session no longer exists is rejected with "Session from session_id claim in JWT does not exist." Our getSession vs getUser guide explains where each check belongs. A password change hits the same gap: Auth deletes the other sessions, but their access tokens kept reading data until this policy was added (Supabase password reset security).
Bug 7: server routes with the secret key, and the client cache
Any API route, Server Action, Edge Function, or cron job that uses the secret key (or legacy service_role key) bypasses RLS. In our test, the service role read all 3 documents across both teams regardless of membership. Code like "fetch the project by id with the admin client" is exactly where removed members, and strangers, get in. OWASP's Authorization Cheat Sheet says permission "should be validated correctly on every request." Prefer the user's own client so RLS applies, and when a route truly needs the secret key, check team_members for the verified user id before touching data.
The client side matters for honesty, not security. If your app caches the team list or documents in React Query, Zustand, or localStorage, a removed member keeps seeing old data until a reload. Treat an empty result or 401 from a team query as "you were removed": clear the cache and send them to their team list.
One removal function that closes every path
This is the server code we tested. The owner's own client does the deletes, so RLS still decides who may remove whom:
// lib/remove-member.ts: server only.
import type { SupabaseClient } from '@supabase/supabase-js';
// supabase: the signed-in owner or admin's own client, so RLS checks they may do this.
// admin: the secret key client, only needed to sign the person out everywhere.
export async function removeMember(
supabase: SupabaseClient,
admin: SupabaseClient,
teamId: string,
userId: string,
{ signOutEverywhere = false } = {},
) {
// 1. Delete the membership row. With live-lookup policies, this alone ends
// their database access on the next request.
const { data: removed, error } = await supabase
.from('team_members')
.delete()
.eq('team_id', teamId)
.eq('user_id', userId)
.select('user_id');
if (error) throw error;
if (!removed?.length) throw new Error('Not allowed, or not a member');
const now = new Date().toISOString();
// 2. Revoke the share links they created for this team's documents.
const { data: docs } = await supabase.from('documents').select('id').eq('team_id', teamId);
const docIds = (docs ?? []).map((d) => d.id);
if (docIds.length) {
const { error: linkError } = await supabase
.from('share_links')
.update({ revoked_at: now })
.eq('created_by', userId)
.is('revoked_at', null)
.in('document_id', docIds);
if (linkError) throw linkError;
}
// 3. Revoke invites they sent that nobody accepted yet.
const { error: inviteError } = await supabase
.from('team_invites')
.update({ revoked_at: now })
.eq('team_id', teamId)
.eq('created_by', userId)
.is('accepted_at', null)
.is('revoked_at', null);
if (inviteError) throw inviteError;
// 4. Offboarding someone completely (an employee who left, an abusive user)?
// End every session so their refresh tokens stop working.
if (signOutEverywhere) {
const { error: e } = await admin.rpc('revoke_user_sessions', { p_user_id: userId });
if (e) throw e;
}
}
What we saw. After removeMember(owner, admin, acme, bob, { signOutEverywhere: true }), Bob's share link and pending invite were marked revoked, his sessions were gone, his old JWT read 0 documents, Carol could no longer use Bob's invite, and a non-member calling removeMember was refused. In total, 47 checks passed across the claim policy, the lookup policy, invites, share links, sessions, and removal.
What AI coding tools get wrong
- Team ids or roles in
app_metadataoruser_metadata, read by RLS.app_metadatagoes stale;user_metadatais worse because users can edit it, which Supabase's linter flags asrls_references_user_metadata. - "Fixing" recursion with
using (true)or by disabling RLS onteam_members, which lets every user list every team's members. See the recursion guide. - Removing the member only in the UI or in local state, or hiding the team in the sidebar while the data stays readable.
- Invite and share links keyed by row id, stored in plain text, with no expiry, no single use, and no tie to the inviter's membership.
- Long-lived signed URLs saved in a column.
- Admin-client routes that load by id without a membership check.
- Assuming
signOut()or a ban ends access instantly. Tokens already issued keep working untilexp. - Raising the JWT expiry to days to "stop users getting logged out," which stretches every stale window above.
How to test it
Run this in your SQL editor with real ids from a test team. It rolls back, so nothing changes. Put the member's real app_metadata (or your hook's claims) in the claims, so a policy that reads the token shows up. If you added the session check, also include "session_id" with a real session id.
begin;
-- 1. The member, before removal: expect their team's document count
set local role authenticated;
set local request.jwt.claims = '{"sub":"<member-user-id>","role":"authenticated","app_metadata":{"teams":["<team-id>"]}}';
select count(*) as member_before from public.documents where team_id = '<team-id>';
-- 2. Remove them, as the SQL editor's own role
reset role;
delete from public.team_members
where team_id = '<team-id>' and user_id = '<member-user-id>';
-- 3. Same claims, no new token: expect 0 on every line
set local role authenticated;
select count(*) as member_docs from public.documents where team_id = '<team-id>';
select count(*) as member_teams from public.teams where id = '<team-id>';
select count(*) as member_members from public.team_members where team_id = '<team-id>';
rollback;
All three counts after the delete must be 0. On our claim-based policy, member_docs stayed at 2; on the lookup policy, it dropped to 0. If yours is above 0, a policy is reading the JWT or something else the member still holds. Then, in the app, with two browsers (owner and member):
- As the member, open a team document, start a Realtime view, create a share link, download a file through a signed URL, and copy an unaccepted invite link.
- As the owner, remove the member.
- As the member, without reloading: refresh the document, write to it, open the share link in a private window, reuse the signed URL, watch the Realtime view, and try the invite link from another account.
- Write down how long each one kept working. Anything beyond the next request needs a reason you are comfortable with, like "signed URLs live 60 seconds."
Then make it permanent with pgTAP tests in CI: our RLS testing guide shows how to run policies as two users, and a "removed member sees nothing" test fits right in.
Checklist
- No policy that guards data reads team ids or roles from
auth.jwt() - Membership lookups go through a
security definerhelper in a non-exposed schema, withsearch_path = ''andEXECUTErevoked fromanon - Only owners and admins can delete members; nobody can insert themselves
- Invite tokens are random, hashed, bound to a verified email, single use, expiring, revocable, and checked against the inviter's role
- Share links are hashed, expiring, revocable, checked on every view, and die with the creator's membership
- Storage policies use the membership helper; signed URLs are created on demand for seconds and never stored
- Realtime uses private channels with membership-based policies; JWT expiry is 1 hour or less
- Offboarding deletes sessions (or the user); sensitive tables check
session_id - Every secret key route checks membership for the verified user id
- The client clears cached team data when a team query comes back empty or
401 - Tested: remove a member, retry every path
Then work through the ship checklist for a vibe-coded app before you share the link.
Copy-paste prompt for your AI coding tool
Paste this into Cursor, Claude Code, Lovable, or Bolt. Share table names and code only, never keys, passwords, or production data.
My Supabase app has teams. When an owner removes a member, the member must lose access on their next request. Audit the project and write one new migration plus server code, and show me everything before running it. 1) Find every RLS policy, storage.objects policy, and realtime.messages policy that reads team ids or roles from auth.jwt(), app_metadata, or user_metadata, and replace it with a lookup in team_members through SECURITY DEFINER functions in a schema named private (language sql, stable, set search_path = '', schema-qualified names, filter by (select auth.uid()) inside, no user id parameter), with execute revoked from public and anon and granted to authenticated. Call them as (select private.fn()). Do not use using (true), do not disable RLS. 2) Only owners and admins may delete team_members rows; nobody may insert their own membership. 3) Invites: random 32-byte token, store only its sha256 hash, bound to the invited email, expires in 7 days, single use, revocable, and accepted through one function that checks and burns the invite in a single UPDATE and requires the inviter to still be an admin. 4) Share links: same token rules, checked on every view on the server, dead if revoked, expired, or if the creator is no longer on the team. 5) Storage: sign URLs on the server after a membership check with expiresIn of at most 300 seconds, never store signed URLs. 6) Find every server route using the secret or service_role key and add a team_members check for the verified user id. 7) Write a removeMember server function that deletes the membership with the owner's own client, revokes that member's share links and pending invites, and optionally deletes their auth.sessions rows through a function only service_role can execute. 8) Give me SQL editor tests with begin, set local role, set local request.jwt.claims, and rollback proving a removed member sees 0 rows with the same claims.
Read the migration before it runs, test it on a branch or local database, and run the test above. More review prompts are in the security review prompt guide.
Sources
- Supabase: Row Level Security (auth.jwt() and app_metadata are not always up to date)
- Supabase: Row Level Security (security definer functions)
- Supabase: RLS performance (wrap functions in select, security definer on join tables)
- Supabase: Column level security
- Supabase: User sessions (token lifetimes, session_id, recommended JWT expiry)
- Supabase JavaScript reference: signOut (access tokens cannot be revoked before expiry)
- Supabase JavaScript reference: auth.admin.signOut (needs the user's JWT)
- Supabase: Managing user data (deleteUser cascades to auth.sessions; bans do not revoke sessions)
- Supabase JavaScript reference: auth.admin.deleteUser
- Supabase JavaScript reference: auth.admin.updateUserById (ban_duration)
- Supabase: Custom Access Token Hook (runs before a token is issued)
- Supabase: Custom claims and RBAC
- Supabase: Securing your API (exposed schemas)
- Supabase: Database functions (security definer, privileges)
- Supabase: lint 0015_rls_references_user_metadata
- Supabase Storage: Serving downloads (signed URLs remain valid until expiry)
- Supabase JavaScript reference: createSignedUrl (expiresIn)
- Supabase Storage: Access control (policies on storage.objects)
- Supabase Realtime: Authorization (policies cached for the connection)
- Supabase Realtime: Postgres Changes
- supabase/realtime source: apply_rls.sql (per-change RLS check, deletes skip it)
- supabase/auth source: tokens/service.go (refresh rejected for banned users)
- supabase/auth source: api/auth.go (session_id must exist)
- supabase/auth migration: sessions table (refresh_tokens cascade on session delete)
- Supabase: Clerk third-party auth (org_id and o claims in RLS)
- Clerk: Session tokens (the o organization claim)
- Clerk: How Clerk works (60 second session tokens)
- Clerk: Authorization checks with has() (Next.js)
- Clerk backend SDK: deleteOrganizationMembership()
- Clerk backend SDK: revokeSession()
- Firebase: Control access with custom claims (propagate on next ID token)
- Firebase: Manage user sessions (revokeRefreshTokens, checkRevoked)
- Firebase: Firestore rules conditions (get() and exists())
- OWASP Top 10 2025: A01 Broken Access Control
- OWASP Authorization Cheat Sheet (validate permissions on every request)
- OWASP Session Management Cheat Sheet (invalidate sessions on the server)
- OWASP Forgot Password Cheat Sheet (random, stored securely, single use, expiring tokens)
- PostgreSQL: Row Security Policies
- PostgreSQL: CREATE POLICY (permissive and restrictive policies)
- PostgreSQL: CREATE FUNCTION (writing SECURITY DEFINER functions safely)
- PostgreSQL: Binary string functions (sha256)
- PostgreSQL: GRANT (column privileges)
- PostgREST: Error reference (PGRST106, 42501)
- MDN: Referrer-Policy
- Node.js: crypto.randomBytes
Questions
Why can a removed user still see team data in Supabase?
Usually because the RLS policy reads the team list from the JWT, for example auth.jwt() -> 'app_metadata' -> 'teams' or a custom access token hook claim. The token is a snapshot from sign-in or the last refresh, so deleting the membership row changes nothing until the token is refreshed, which is up to 1 hour by default. Supabase's RLS docs say this directly. Write the policy against the memberships table instead, through a security definer helper, and access ends on the next request.
Does signing a user out revoke their Supabase access token?
No. Signing out deletes the session and its refresh tokens, so no new access tokens can be minted, but an access token already issued stays valid until its exp claim. Supabase's signOut reference says there is no way to revoke an access token before it expires. Keep the JWT expiry short, use membership lookups in RLS, and for the most sensitive tables add a policy that checks the token's session_id still exists in auth.sessions.
How do I sign out a Supabase user by user id from the server?
auth.admin.signOut needs the user's JWT, which your server usually does not have. Two options: delete the user with auth.admin.deleteUser, which cascades to auth.sessions and kills the refresh tokens, or delete their rows from auth.sessions with a security definer function that only the service_role can execute. Either way, tokens already issued keep working until they expire, so the membership check in RLS still matters.
Do Supabase Storage signed URLs stop working when I remove someone from a team?
No. A signed URL is checked against its own signature and expiry, not against your RLS policies or the user's membership. Supabase's docs say signed URLs remain valid until their expiry time, even through Auth key changes, and that revoking them needs Supabase support. Create signed URLs on the server after a membership check, with a short expiresIn such as 60 seconds, and never store them in the database.
Is a share link with a random id in the URL enough?
Not on its own. A link like /share/<document-id> served with the secret key works for anyone who has it, forever, and keeps working after the person who shared it leaves. Use a random 256-bit token, store only its sha256 hash, give it an expiry and a revoked_at column, check it on every view, and require that the person who created it is still a member of the team.