Row Level Security
The most important security feature in Supabase — controlling who can see which rows at the database level.
Why First: The Client Data Disaster
You're a CA with 50 clients. You've built a simple web portal where clients log in and download their reports.
You build the filtering in React: when a client logs in, you fetch their data and display only theirs. It works in testing. You go live.
A technically curious client opens the browser's Network tab. They see the API request your app makes to fetch data. They copy the request URL and remove the filter — or they modify the client ID in the request body. Your server returns data for all 50 clients. All the financial records. All the income figures. All the confidential filings.
You've just had a data breach. Not because your code was hacked. Because you relied on client-side filtering as your security.
Client-side filtering is UI sugar. It controls what gets displayed. It does not control what gets returned by your database. A technical user can always bypass it.
Row Level Security (RLS) closes this gap. It enforces access control in the database itself — before data even leaves the database server.
What RLS Is
Row Level Security is a feature of PostgreSQL (the database Supabase uses). When enabled on a table, it applies a policy to every query — regardless of who makes the query or how.
Without RLS: a determined user bypasses the app layer and hits the database directly via the Supabase anon key (which is in your frontend code).
With RLS: there is no way to bypass this. The database enforces it on every query, period.
The visual below shows the same fee_records table queried by three different users. The database returns different rows to each — not because the app filtered them, but because the server never sent the forbidden rows at all.
Three users, same table, same query — Supabase enforces RLS and returns only what each role is permitted to see.
Enabling RLS
RLS is disabled by default. You must explicitly enable it on every table.
Writing Policies
A policy is a SQL expression that evaluates to true or false for each row. If it returns true for a given row and a given user, that user can access that row. If it returns false, the row is invisible.
The key function is auth.uid() — this returns the UUID of the currently authenticated user.
The most common pattern: users see only their own data
Breaking this down:
FOR SELECT— this policy applies to read queriesUSING (auth.uid() = user_id)— the row is only returned if the logged-in user's ID matches theuser_idcolumn in that row
A candidate with user ID aaa-111 can only see rows where user_id = 'aaa-111'. The row belonging to user bbb-222 is completely invisible — not filtered in the response, not even fetched from disk.
Public data: anyone can read
USING (true) means the policy always passes — any user (even not logged in) can read these rows. Use this for reference data like a list of cities or job categories.
TO authenticated restricts a policy to logged-in users only. WITH CHECK on INSERT means the condition must be true for the row being inserted.
Admin access: see everything
Use a helper function that you reference in all admin policies rather than repeating the admin check inline in every policy. One function, many references — easier to maintain.
Recruiter access: see only their company's data
Multiple policies on the same table coexist. PostgreSQL combines them with OR — if any policy returns true for a row, the row is returned. A recruiter sees their company's jobs (via the recruiter policy) plus any active public jobs (via the public policy).
The SECURITY DEFINER Pattern
You will see SECURITY DEFINER on helper functions throughout Udyogaseva. Here's why it exists.
The problem: To check if a user is a recruiter, you query the recruiters table. But the recruiters table has RLS enabled. When a policy on another table tries to check the recruiters table, it triggers that table's RLS policy. That policy tries to check the recruiters table again. Infinite recursion. PostgreSQL returns an error.
The solution: A SECURITY DEFINER function runs with the permissions of the function's owner (usually the database admin), not the current user's permissions. It bypasses RLS on the tables it queries. This lets you safely look up things like "what company does this recruiter belong to?" without recursion.
The STABLE keyword tells PostgreSQL this function's result doesn't change during a single query — the database can cache it instead of calling it once per row.
Testing Your RLS Policies
RLS policies are only valuable if you verify they work.
You can also test directly in the SQL Editor using SET ROLE to impersonate different users — but the simplest verification is using your actual test accounts through the app's UI and checking the Network tab.
RLS bypasses happen with the service_role key. When you use the service_role key in an edge function, all RLS is bypassed — the function can read and write everything. This is intentional for server-side operations. But it also means edge functions must implement their own authorisation checks — they cannot rely on RLS to protect them. Always verify the user's role and ownership inside edge functions before writing data.
The Two-Layer Security Model
In Udyogaseva (and in all well-designed apps), security works in two layers:
Layer 1 — Database (RLS): Controls which rows are accessible. No matter what the application does, the database only returns what the policy allows.
Layer 2 — Application (frontend route guards): Controls which pages users can navigate to. A candidate who somehow navigates to /admin/users should be redirected — even though RLS already means they cannot see any admin data.
Both layers are required. RLS protects the data. Route guards protect the user experience and prevent confusion. Neither alone is sufficient.
Common Mistakes to Avoid
Not enabling RLS at all. A table without RLS enabled is completely open — anyone with the anon key can read all rows.
Enabling RLS but forgetting policies. All access is denied. Your app suddenly returns no data. Add policies immediately after enabling RLS.
Writing client-side-only filtering. Your app filters the returned data in JavaScript. But the database still returned all rows over the network. RLS is the only real protection.
Testing only with your admin account. Always test with low-privilege accounts. Admin accounts bypass everything — they will not reveal RLS gaps.
Forgetting to grant execute on helper functions. If a policy calls a helper function but the function is not granted execute permission to authenticated and anon, the policy silently fails and access is denied.
See It in Action
Switch between user roles below. Watch which rows disappear — that is RLS working. Same database, same data, different views based on who is logged in.
test_notes with columns: id, user_id, content)auth.uid() = user_id. Operation: SELECT.- I can explain why client-side filtering is not secure — and what attack it fails to prevent
- I understand what
auth.uid()is and how it's used in an RLS policy expression - I have enabled RLS on at least one table in my Supabase project
- I have written at least one SELECT policy and verified it filters correctly
- I understand the two-layer security rule: RLS in the database + protected routes in the app — both required