Follow-up to #226 / #234, which ships tracker sync using the Supabase
service role key.
Where things stand
web/lib/tracker-store.ts authenticates with SUPABASE_SERVICE_ROLE_KEY, which
bypasses RLS. Ownership of a tracker row is therefore enforced in application
code — by the .eq("user_id", userId) on every read, update and delete, and by
writing user_id explicitly on every insert. The userId always comes from the
Clerk session resolved in the route handler, never from a request body.
That is correct as written, and the surface is deliberately small: four
functions, all short, all requiring userId as a parameter rather than taking it
as an option.
The cost is that the database is not a backstop. A missing filter in that one
file would be a cross-user read, and RLS could not catch it — service_role is
precisely the role those policies don't apply to.
The policies added in 20260725154500_user_hackathons.sql are already written
against auth.jwt() ->> 'sub':
create policy "read own tracker"
on public.user_hackathons for select
to authenticated
using ((select auth.jwt() ->> 'sub') = user_id);
They currently do useful work — anon and authenticated can't touch the table
at all, so nothing reachable with a publishable key can read someone's tracker —
but the owner-matching half has never actually matched anything, because no
authenticated request carries a Clerk sub that Supabase trusts yet.
What to do
Configure Clerk as a third-party auth provider in Supabase so the browser's
own Clerk session token satisfies those policies directly, then move
tracker-store.ts off the service role and onto the user's token.
Why it isn't urgent
user_hackathons holds no user data yet, and app-layer scoping is genuinely
enforced today. This is about removing a single point of failure, not fixing a
live hole. It needs coordinated dashboard access on two services, which is why it
was split out rather than blocking #226 and #223.
Reference: Clerk × Supabase third-party auth
Follow-up to #226 / #234, which ships tracker sync using the Supabase
service role key.
Where things stand
web/lib/tracker-store.tsauthenticates withSUPABASE_SERVICE_ROLE_KEY, whichbypasses RLS. Ownership of a tracker row is therefore enforced in application
code — by the
.eq("user_id", userId)on every read, update and delete, and bywriting
user_idexplicitly on every insert. TheuserIdalways comes from theClerk session resolved in the route handler, never from a request body.
That is correct as written, and the surface is deliberately small: four
functions, all short, all requiring
userIdas a parameter rather than taking itas an option.
The cost is that the database is not a backstop. A missing filter in that one
file would be a cross-user read, and RLS could not catch it —
service_roleisprecisely the role those policies don't apply to.
The policies added in
20260725154500_user_hackathons.sqlare already writtenagainst
auth.jwt() ->> 'sub':They currently do useful work —
anonandauthenticatedcan't touch the tableat all, so nothing reachable with a publishable key can read someone's tracker —
but the owner-matching half has never actually matched anything, because no
authenticatedrequest carries a Clerksubthat Supabase trusts yet.What to do
Configure Clerk as a third-party auth provider in Supabase so the browser's
own Clerk session token satisfies those policies directly, then move
tracker-store.tsoff the service role and onto the user's token.dashboard. Requires admin on both.
sublands where the policies read it.web/lib/tracker-store.tsfrom theservice role key to the request's Clerk token.
.eq("user_id", …)filters are now belt-and-braces rather thanthe only guard — keep them, but they should stop being load-bearing.
hackathon_idshould be rejected by Postgres, not by app code. Test it with the app
filters temporarily removed.
SUPABASE_SERVICE_ROLE_KEYfrom the runtime environment if nothingelse needs it, and update
web/.env.example+web/README.md.user_id text not null default auth.jwt() ->> 'sub'— dead on theservice-role path, live once this lands.
Why it isn't urgent
user_hackathonsholds no user data yet, and app-layer scoping is genuinelyenforced today. This is about removing a single point of failure, not fixing a
live hole. It needs coordinated dashboard access on two services, which is why it
was split out rather than blocking #226 and #223.
Reference: Clerk × Supabase third-party auth