DEV Community

kanta13jp1
kanta13jp1

Posted on

PostgreSQL RLS Guide: Multi-Tenant Security with Supabase

PostgreSQL RLS Guide: Multi-Tenant Security with Supabase

Row Level Security (RLS) is one of PostgreSQL's most powerful features. In Supabase, it's essential — and a misconfiguration means data leaks. Here's how to get it right.

The Basics

-- Enable RLS on a table
ALTER TABLE user_notes ENABLE ROW LEVEL SECURITY;

-- With RLS enabled and no policies, nobody can access any rows (default deny)
-- Always add policies after enabling
Enter fullscreen mode Exit fullscreen mode

Core Pattern: Users Can Only See Their Own Data

-- SELECT: read own rows only
CREATE POLICY "user_can_read_own_notes"
  ON user_notes
  FOR SELECT
  USING (auth.uid() = user_id);

-- INSERT: only allowed with matching user_id
CREATE POLICY "user_can_insert_own_notes"
  ON user_notes
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

-- UPDATE: own rows only
CREATE POLICY "user_can_update_own_notes"
  ON user_notes
  FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

-- DELETE: own rows only
CREATE POLICY "user_can_delete_own_notes"
  ON user_notes
  FOR DELETE
  USING (auth.uid() = user_id);
Enter fullscreen mode Exit fullscreen mode

USING = filter on read / WITH CHECK = filter on write.

Admin Access: service_role Bypasses RLS

CREATE POLICY "service_role_full_access"
  ON user_notes
  FOR ALL
  USING (auth.jwt() ->> 'role' = 'service_role');
Enter fullscreen mode Exit fullscreen mode

Edge Functions use SUPABASE_SERVICE_ROLE_KEY → RLS bypassed. Flutter clients use anon key → RLS enforced.

Public/Private Toggle Pattern

CREATE POLICY "public_notes_readable_by_all"
  ON public_notes
  FOR SELECT
  USING (is_public = true OR auth.uid() = user_id);

-- Public notes: anyone can read
-- Private notes: only owner can read
Enter fullscreen mode Exit fullscreen mode

Team Access: Share Within Organization

CREATE POLICY "team_members_can_read_team_notes"
  ON team_notes
  FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM team_members
      WHERE team_members.team_id = team_notes.team_id
        AND team_members.user_id = auth.uid()
    )
  );
Enter fullscreen mode Exit fullscreen mode

Flutter Integration

// Flutter uses anon key — RLS is automatically enforced
final supabase = Supabase.instance.client;

// Only returns rows belonging to the current user
final notes = await supabase
    .from('user_notes')
    .select('*');

// INSERT: user_id is checked by WITH CHECK policy
await supabase.from('user_notes').insert({
  'title': 'New note',
  'user_id': supabase.auth.currentUser!.id,
});
Enter fullscreen mode Exit fullscreen mode

Debugging RLS

-- Test policies as a specific user
SET LOCAL role TO authenticated;
SET LOCAL request.jwt.claims TO '{"sub": "user-uuid-here"}';

SELECT * FROM user_notes;  -- returns policy-filtered results
RESET role;
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

-- ❌ BAD: INSERT without user_id — fails RLS check
INSERT INTO user_notes (title) VALUES ('test');
-- ERROR: new row violates row-level security policy

-- ✅ GOOD: always include user_id
INSERT INTO user_notes (title, user_id)
VALUES ('test', auth.uid());

-- BETTER: set user_id as default
ALTER TABLE user_notes
  ALTER COLUMN user_id SET DEFAULT auth.uid();
Enter fullscreen mode Exit fullscreen mode

Checklist

RLS design checklist:
  ☑ ENABLE ROW LEVEL SECURITY on every table
  ☑ Create policies for SELECT / INSERT / UPDATE / DELETE
  ☑ Use auth.uid() to scope access to own rows
  ☑ service_role key stays server-side (never in Flutter)
  ☑ Set DEFAULT auth.uid() on user_id columns
Enter fullscreen mode Exit fullscreen mode

With RLS correctly designed, you don't need application-layer filtering. Security is enforced at the database level — the right place for it.

Top comments (0)