The whole data model

The OAuth sibling has three tables (workspaces, members, snippets), membership roles, a visibility enum, and a page of RLS policies — all in service of per-user access control. We threw that away in step 1. With one shared identity, the model collapses to a single flat table.

Create a migration:

supabase migration new init_snippets_schema

Open the generated file under supabase/migrations/ and paste:

create table public.snippets (
  id          uuid primary key default gen_random_uuid(),
  title       text not null check (char_length(title) between 1 and 120),
  body        text not null check (char_length(body)  between 1 and 60000),
  tags        text[] not null default array[]::text[],
  -- Optional free-text note of who wrote it. There is no real identity in a
  -- shared-token server; the caller supplies this label if they want it.
  author      text,
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now()
);
 
create index snippets_tags_idx on public.snippets using gin (tags);
 
-- Keep updated_at fresh on any UPDATE.
create or replace function public.touch_updated_at()
returns trigger language plpgsql as $$
begin
  new.updated_at := now();
  return new;
end $$;
 
create trigger snippets_touch_updated_at
  before update on public.snippets
  for each row execute function public.touch_updated_at();

The tags GIN index makes the list_snippets tag filter fast even as the library grows. The trigger means an update tool call never has to remember to bump updated_at.

The important part: RLS on, no policies

Add this to the bottom of the migration:

alter table public.snippets enable row level security;
-- ...and no `create policy` statements. On purpose.

This looks like a mistake. It isn't. Here's the reasoning, because it's the security model of the entire build:

  • RLS enabled + zero policies = deny all for the anon and authenticated roles. Your project's anon key is public (it ships in browser code all over the Supabase ecosystem), so anyone could point supabase-js at your project and hit PostgREST directly. With RLS on and no policies, every such attempt reads and writes nothing.
  • The service-role key bypasses RLS entirely. It's a superuser-ish key that represents the whole project. Our Edge Function uses it (step 5) to reach the table — and that key lives only in the function's secrets, behind the token check.

Put together: the shared token in front of the function is the access control, and this locked table is the fence that stops anyone from walking around the function to reach the data. Both halves matter. If you skipped enable row level security, the anon key could read your snippets directly. If you used the anon key inside the function instead of the service role, every query would come back empty.

Contrast with the OAuth build. There, the whole point is the opposite: never use the service role, write real per-user policies, and let Postgres decide access from each user's token. That only works because each request carries a distinct identity. We don't have one, so we lean on the token gate + a locked table instead. Neither is "more correct" — they're the two honest answers to "do you have per-user identity?"

Apply it

Push to the cloud project:

supabase db push

Confirm the table exists and RLS is on:

# In the Supabase dashboard: Table Editor → snippets should show a shield icon
# (RLS enabled). Or via SQL editor:
select relname, relrowsecurity
from pg_class
where relname = 'snippets';
-- relrowsecurity should be `true`

Prove the lock works (optional but reassuring)

With the anon key, a direct read should return an empty set, not an error and not data:

curl "https://<ref>.supabase.co/rest/v1/snippets?select=*" \
  -H "apikey: <your-anon-key>" \
  -H "Authorization: Bearer <your-anon-key>"
# []   ← RLS denies the anon role; the table is unreachable this way

If that returns rows, RLS didn't get enabled — fix the migration before moving on. If it returns [], the fence is up. Step 4 builds the gate: the shared-token check.