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_schemaOpen 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
anonandauthenticatedroles. 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 pushConfirm 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 wayIf 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.