What you'll build
Right now, the only thing stopping User A from reading User B's tasks is a userId filter in your server actions. If someone calls your Supabase client directly with the anon key and skips that filter, they can read everything.
In this part you'll add Row Level Security (RLS) policies that enforce data isolation at the database level — so no query, from any client, can return rows that don't belong to the requesting user. You'll also add role-based access so team members can be granted access to a project's tasks.
By the end of this part:
- The tasks table enforces per-user isolation via RLS
- A
projectstable lets you group tasks and assign team members - Team members can read and update tasks in projects they belong to
- Admins can do everything; members can only read and update (not delete)
How RLS + Firebase UID works
Supabase RLS policies run inside Postgres using the auth.uid() function — which returns the user ID from the JWT the client sends. By default this is a Supabase-issued JWT.
We're using Firebase Auth, which issues its own JWTs. To make Supabase trust Firebase tokens, we set up a custom JWT verifier. Supabase lets you configure a third-party auth provider (or use a custom JWT secret) to do exactly this.
Option A — Supabase Third-Party Auth (recommended)
In the Supabase dashboard, go to Authentication → Sign In with Firebase and follow the wizard. Supabase will verify incoming Firebase ID tokens and expose auth.uid() as the Firebase UID.
Option B — Pass UID via a custom claim
If Option A isn't available on your plan, pass the Firebase UID as an app-metadata claim when creating the Supabase session. Your server actions already have the UID — you can inject it as a custom request.jwt.claims header.
Either approach makes auth.uid() in Postgres equal to the Firebase UID stored in tasks.user_id.
Add the RLS policies
Replace the permissive policy from Part 4 with proper per-user policies:
-- Drop the permissive policy from Part 4
drop policy if exists "Users can read own tasks" on tasks;
-- Users can only see their own tasks
create policy "select own tasks"
on tasks for select
using (auth.uid()::text = user_id);
-- Users can only insert tasks for themselves
create policy "insert own tasks"
on tasks for insert
with check (auth.uid()::text = user_id);
-- Users can only update their own tasks
create policy "update own tasks"
on tasks for update
using (auth.uid()::text = user_id);
-- Users can only delete their own tasks
create policy "delete own tasks"
on tasks for delete
using (auth.uid()::text = user_id);
Run this in the Supabase SQL Editor.
Add a projects table with team membership
A task manager is more useful when tasks belong to projects that teams can share:
create table projects (
id uuid primary key default gen_random_uuid(),
name text not null,
owner_id text not null,
created_at timestamptz not null default now()
);
create table project_members (
project_id uuid references projects(id) on delete cascade,
user_id text not null,
role text not null default 'member'
check (role in ('admin', 'member')),
joined_at timestamptz not null default now(),
primary key (project_id, user_id)
);
-- Add project_id to tasks (nullable — tasks can be personal or in a project)
alter table tasks add column project_id uuid references projects(id) on delete set null;
Add team-aware RLS policies
Add Row Level Security policies to the projects and project_members tables in Supabase. For projects: - Owners can do everything (select, insert, update, delete) - Members (in project_members) can select For tasks with a project_id set: - Project admins can select, insert, update, delete - Project members can select and update, but not delete For tasks without a project_id (personal tasks): - Only the owner (user_id) can do anything Write the SQL policies. Use auth.uid()::text for comparisons against text user_id columns. Use EXISTS subqueries to check project_members for membership and role.
Check the generated SQL carefully before running it — RLS policies are security-critical. The EXISTS subquery pattern for checking membership looks like this:
exists (
select 1 from project_members
where project_members.project_id = tasks.project_id
and project_members.user_id = auth.uid()::text
and project_members.role = 'admin'
)
Update server actions to use the anon key
Now that RLS is enforcing access control, server actions can switch from the service role key to the anon key — the database will reject any query that violates a policy, regardless of which client is used:
Update src/lib/supabase.ts. The createServerClient function should now use NEXT_PUBLIC_SUPABASE_ANON_KEY instead of SUPABASE_SERVICE_ROLE_KEY. The server actions in src/lib/task-actions.ts need to pass the user's Firebase ID token as a Bearer token in the Authorization header when creating the Supabase client, so that auth.uid() in RLS policies resolves to the correct Firebase UID.
Verify isolation
Test that RLS is working by bypassing your app and calling Supabase directly. In your browser console on the dashboard page:
const { createClient } = await import("@supabase/supabase-js");
const sb = createClient(
"https://xxxxxxxxxxxx.supabase.co",
"your_anon_key"
);
const { data } = await sb.from("tasks").select("*");
console.log(data);
Without a valid auth token, this should return an empty array or a permission error — not another user's tasks. If it returns all tasks, check that RLS is enabled on the table and your policies are correct.
Project structure after Part 5
src/
lib/
supabase.ts ← createServerClient now uses anon key + auth header
task-actions.ts ← passes Firebase ID token to Supabase client
-- Tables
tasks ← per-user and per-project RLS policies
projects ← owner-based RLS
project_members ← membership table, role column
What's next
In Part 6 — the final part — you'll deploy the finished app to Cloudflare Pages. BlocWeave will handle the build configuration, environment variable setup, and the Cloudflare Pages config file.
BlocWeave