Step 01: Introduction
A login screen tells you who someone is. It does not decide which records they may access. If your app stores private notes, one signed-in user must never be able to read another user's notes by changing an ID in a request.
This tutorial builds a small ownership model you can inspect, extend, and test. The example is deliberately narrow: each note belongs to one user, and only that user can manage it.
Step 02: The three pieces of database access
Keep these responsibilities separate:
- Authentication: establish the caller's identity.
- Privileges: allow a database role to perform an operation on a table.
- Row Level Security: limit the rows available to that operation.
Supabase Auth connects a signed-in request to the
authenticateddatabase role. Itsauth.uid()helper exposes the request's user ID. Enable RLS explicitly in migrations, and grant only the operations your application needs.Reference: Supabase Row Level Security.
Step 03: Start with an access rule
Before writing SQL, write the product requirement:
A user can create, read, edit, and delete their own notes. Signed-out visitors cannot access notes. A user cannot transfer a note to someone else.
The last sentence matters. Checking who owns an existing record is different from checking the ownership value after an update.
Step 04: A complete private-notes migration
Run this against a development Supabase project as a migration. It creates a new table; adapt the name if your project already contains
public.private_notes.sqlbegin; create table public.private_notes ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references auth.users(id) on delete cascade, title text not null, body text not null default '', created_at timestamptz not null default now() ); alter table public.private_notes enable row level security; revoke all on table public.private_notes from public, anon, authenticated; grant select, insert, update, delete on table public.private_notes to authenticated; create index private_notes_owner_idx on public.private_notes (owner_id); create policy "notes_read_own" on public.private_notes for select to authenticated using ((select auth.uid()) = owner_id); create policy "notes_create_own" on public.private_notes for insert to authenticated with check ((select auth.uid()) = owner_id); create policy "notes_update_own" on public.private_notes for update to authenticated using ((select auth.uid()) = owner_id) with check ((select auth.uid()) = owner_id); create policy "notes_delete_own" on public.private_notes for delete to authenticated using ((select auth.uid()) = owner_id); commit;USING versus WITH CHECK
Clause Question it answers Example USINGMay this caller access the existing row? Is the current note mine? WITH CHECKIs the proposed row allowed? Will the saved note still belong to me? An insert checks the new row. An update in this example checks both the existing and proposed ownership values. Keep the SELECT policy alongside UPDATE so the owner can locate the row through normal API queries.
With RLS enabled, the absence of a matching policy denies normal row access. Database owners and roles with
BYPASSRLScan bypass that boundary. Also remember that default permissive policies combine with OR: adding a broad allow policy can undo the intended restriction.Reference: PostgreSQL Row Security Policies.
Step 05: Use the signed-in client
This JavaScript example assumes
supabaseis an initialized client using a publishable key and the current user's session:tsconst { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { throw new Error('Please sign in.'); } const { error: insertError } = await supabase .from('private_notes') .insert({ owner_id: user.id, title: 'First private note', body: 'Visible to this account only.', }); if (insertError) throw insertError; const { data: notes, error: readError } = await supabase .from('private_notes') .select('id, title, body, created_at') .eq('owner_id', user.id) .order('created_at', { ascending: false }); if (readError) throw readError;The explicit owner filter helps express query intent and can help query planning. It is not the authorization boundary: the database policy must still reject a request with a different owner ID.
Index policy filter columns, specify the target role, and consider the
(select auth.uid())form for a value that does not vary by row. Measure representative queries before making further optimizations.Reference: Supabase RLS performance.
Step 06: Test with two accounts, not just one
Create two test accounts, Alice and Bob, plus a signed-out client. Record actual returned rows and database changes.
Attempt Expected outcome Alice inserts a note owned by Alice Insert succeeds Alice reads her note The note is returned Bob queries Alice's note ID No note is returned Bob updates or deletes Alice's note Alice's note remains unchanged Bob inserts a note with Alice's owner ID Insert is rejected Alice changes her note's owner to Bob Update is rejected Signed-out client accesses the table Access is denied by the table privileges Do not treat an error-free response as proof that a write succeeded. A filtered update may affect zero rows. Verify the stored result and repeat each operation with the intended role.
Automate the allow and deny cases in database tests, and keep them beside the migration. This article's examples are instructional; validate them in your own development environment before deployment.
Step 07: Keep privileged credentials out of the request path
A publishable key is designed for client applications; the user's session supplies identity. Secret keys and legacy service-role credentials are privileged and must stay in trusted server environments.
For ordinary user actions, use a client carrying that user's session. Reserve privileged clients for narrowly defined administrative work with explicit authorization checks. A successful request made through a privileged client does not demonstrate that your user-facing policies work.
Reference: Supabase API keys.
Step 08: Row access is not column access
An ownership policy does not make selected columns immutable or hide sensitive fields within an allowed row. If a table contains an administrative role, billing flag, or internal decision, do not assume the owner policy protects that field.
Use a separate protected table or carefully designed column privileges and trusted server operations. Review both table-level and column-level grants, because a broad table privilege can defeat a narrower column restriction.
Reference: Supabase Column Level Security.
Step 09: A review habit worth keeping
For every new table, describe who should access each operation, implement that rule in a migration, and demonstrate both an allowed request and a denied request. Revisit the tests whenever you add sharing, teams, public content, or administrative access.
A small ownership model is a useful starting point. Its value comes from making the boundary explicit enough that another developer can understand and verify it.



