The Situation
It was a Tuesday. Spindare's auth had been running on a vanilla Clerk setup for two months — email/password, Google OAuth, JWT to Supabase via the Clerk webhook. It worked. Users could sign in, sign out, sessions persisted, nothing was on fire.
Then we decided to add the ban system.
Why the Old Setup Broke Down
The requirement was simple: banned users should be blocked at every layer — API calls, real-time subscriptions, even reading public data. Our old flow:
1. User signs in via Clerk
2. Clerk issues a JWT
3. JWT is passed to Supabase as a custom auth token
4. Supabase RLS policies check auth.uid()
The problem: Clerk's JWT had a 1-hour expiry. A banned user who was active when the ban happened would continue to have valid Supabase access for up to 60 minutes. We had no mechanism to invalidate their session mid-flight.
On top of that, our Supabase RLS policies were checking auth.uid() but our ban table lived in a separate schema that wasn't being checked at the policy level. Banned users could still read posts, still open WebSocket channels. The ban only blocked writes.
The 48-Hour Rewrite
I didn't want to patch this. Patching auth is how you get security holes. I decided to rebuild the entire auth flow with the ban system as a first-class citizen.
New architecture:
Clerk (identity) → Edge Function (validation + ban check) → Supabase JWT (short-lived)Every authenticated Supabase request now goes through a Supabase Edge Function that:
1. Validates the Clerk JWT
2. Checks the ban table for the user's UID
3. If banned, returns 403 immediately
4. If clean, mints a new short-lived Supabase JWT (15 min expiry) and returns it
The client caches this token and refreshes it before expiry. If a ban happens mid-session, the next token refresh fails with 403 and the client is forced to sign out.
// Edge Function: /functions/v1/auth-token
const { userId } = await verifyClerkToken(req.headers.get('Authorization'));
const { data: ban } = await supabase
.from('bans')
.select('id')
.eq('user_id', userId)
.single();
if (ban) {
return new Response('Forbidden', { status: 403 });
}
const token = await mintSupabaseJWT(userId);
return new Response(JSON.stringify({ token }), { status: 200 });RLS policies now check a custom claim in the JWT rather than auth.uid() directly, so the policy layer is tightly coupled to our token minting logic.
What I'd Do Differently
Start with short-lived tokens. The 1-hour Clerk JWT was a mistake from day one. Any auth token that lasts more than 15 minutes in a mobile app is asking for problems. Short tokens force you to build proper refresh logic, which then makes mid-session invalidation essentially free.
Design the ban system before auth, not after. We built auth assuming all users were valid indefinitely. Adding bans after the fact required a full rewrite. If I'd sketched the trust model upfront — "how do we revoke access instantly?" — the architecture would have been right the first time.
Don't be afraid to rewrite. 48 hours felt like a lot when I started. Looking back, it was the right call. The patched version would have had subtle holes that I'd be hunting down for months.
The Result
Bans now take effect within 15 minutes maximum (the token refresh window). For serious violations we have a force-invalidation endpoint that nukes the cached token client-side via a push notification. The RLS layer is tight, auditable, and doesn't have any "works fine until it doesn't" edge cases.
Auth is one of those things where doing it right once is worth far more than patching something indefinitely.