Skip to content

Move tracker ownership enforcement into Postgres (Clerk↔Supabase third-party auth) #235

Description

@Jose-Gael-Cruz-Lopez

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.

  • Configure the trust relationship — Clerk dashboard and Supabase
    dashboard. Requires admin on both.
  • Verify a Clerk-issued token's sub lands where the policies read it.
  • Switch the client construction in web/lib/tracker-store.ts from the
    service role key to the request's Clerk token.
  • Confirm the .eq("user_id", …) filters are now belt-and-braces rather than
    the only guard — keep them, but they should stop being load-bearing.
  • Prove enforcement moved: a request forging another user's hackathon_id
    should be rejected by Postgres, not by app code. Test it with the app
    filters temporarily removed.
  • Drop SUPABASE_SERVICE_ROLE_KEY from the runtime environment if nothing
    else needs it, and update web/.env.example + web/README.md.
  • Revisit user_id text not null default auth.jwt() ->> 'sub' — dead on the
    service-role path, live once this lands.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendServer / data / SupabasesecuritySecurity vulnerability or hardening

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions