GetLaunchpad
Back to blog

Supabase Row Level Security in Next.js: a practical guide

Row Level Security is Supabase's most important security feature — and the most commonly skipped. Here's how to use it correctly in a Next.js application.

Most Supabase tutorials show you how to query data. Few spend meaningful time on Row Level Security — the feature that determines who is actually allowed to read or write that data. The result is a common pattern: developers build on Supabase, ship to production, and discover later that their anon key exposes rows it should never have touched. RLS is not a nice-to-have. It is the difference between a database that is secure by default and one that relies entirely on your application code never making a mistake.

What RLS is and why application-level filtering is not enough

Row Level Security is a PostgreSQL feature — and therefore a Supabase feature — that enforces access control at the database layer. When RLS is enabled on a table, every query against that table is automatically filtered by the policies you define. A user can only see rows the policy says they can see, regardless of what SQL the application sends.

Application-level filtering looks like this:

// Application-level filter — dangerous if forgotten or bypassed
const { data } = await supabase
  .from("users")
  .select("*")
  .eq("clerk_id", userId);

This works — until a developer forgets the .eq() filter, or until a bug introduces a code path that skips it, or until a future contributor does not know the convention exists. RLS enforces the same constraint at the database layer, where it cannot be accidentally omitted:

-- This policy runs for every SELECT on the users table
-- regardless of what the application code sends
CREATE POLICY "users can read own row"
  ON users FOR SELECT
  USING (auth.uid()::text = clerk_id);

With this policy in place, even if your application sends SELECT * FROM users with no filter, the database returns only the rows belonging to the authenticated user. Defense in depth — the application filter is still good practice, but the database is the last line of defense.

Enabling RLS on your tables

Supabase creates tables with RLS disabled by default. Enabling it is one SQL statement per table:

ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

Important: enabling RLS with no policies is not the same as leaving RLS disabled. With RLS enabled and no policies defined, the default behavior is deny-all — no rows are returned for any query. This is the safe default, but it will break your application until you write the policies you actually need.

Writing policies for the users and subscriptions tables

A complete set of policies for a typical SaaS schema covers read and write access for authenticated users, plus service-role access for system operations (webhook handlers, admin scripts):

-- Users table: each user can read and update their own row.
-- Inserts are handled server-side via the admin client (service role).
CREATE POLICY "users: select own"
  ON users FOR SELECT
  USING (auth.uid()::text = clerk_id);

CREATE POLICY "users: update own"
  ON users FOR UPDATE
  USING (auth.uid()::text = clerk_id);

-- Subscriptions table: users can read their own subscription.
-- All writes come from the Stripe webhook handler (admin client),
-- so there is no user-facing INSERT or UPDATE policy.
CREATE POLICY "subscriptions: select own"
  ON subscriptions FOR SELECT
  USING (
    clerk_id = auth.uid()::text
  );

Notice what is missing: there are no INSERT or UPDATE policies on subscriptions. Subscription state is written exclusively by the Stripe webhook handler, which uses the admin client (service role key). The admin client bypasses RLS entirely — which is exactly what you want for a system actor that is writing on behalf of Stripe, not on behalf of a user.

The three Supabase client tiers

Understanding which client to use in which context is the most important practical skill for working with Supabase securely in Next.js.

Browser client (anon key, RLS enforced)

Use this in client components. The anon key is safe to expose — it is intentionally public — but it only grants access to rows the RLS policies permit. If you have written your policies correctly, this client cannot read another user's data.

// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

Server client (anon key, RLS enforced, cookie-based auth)

Use this in Server Components and Route Handlers. It reads the user's session from cookies rather than browser storage, so it works in server-side rendering contexts where there is no window object. RLS is still enforced.

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { getAll: () => cookieStore.getAll() } }
  );
}

Admin client (service role key, RLS bypassed)

Use this only in server-side code for system operations: webhook handlers, background jobs, admin scripts. The service role key bypasses RLS entirely. It must never be imported in a client component — mark the file with "server-only" to make that a build error.

// lib/supabase/admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";

export const adminClient = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

Common RLS mistakes

Forgetting to enable RLS

The most common mistake. Tables default to RLS-off, which means the anon key can read every row. Always enable RLS as part of your table creation, not as an afterthought. Putting ALTER TABLE ... ENABLE ROW LEVEL SECURITY directly in your schema SQL file means it cannot be forgotten.

Using the service role key in browser components

The service role key bypasses all RLS policies. If it ends up in a client component, it gets bundled into your JavaScript output and sent to every visitor's browser. Anyone who opens DevTools can read it. Mark your admin client file with import "server-only" — Next.js will throw a build error if that module is imported from a client component, which is exactly the guardrail you want.

Writing policies that are too permissive

A common shortcut during development is to write a blanket policy like USING (true) so everything works, with the intent to tighten it later. Later never comes. Write the correct policy from the start. The auth.uid()function gives you the authenticated user's UUID, which you can compare against a clerk_id column or a user_id foreign key.

No policy on INSERT

RLS policies are operation-scoped: SELECT, INSERT, UPDATE, DELETE. Enabling RLS and writing a SELECT policy does not protect against unauthorized inserts. Think through each operation for each table and decide who should be allowed to perform it.

Testing your RLS policies

The Supabase SQL editor lets you impersonate a specific user for testing. Use SET LOCAL role TO anon to simulate an unauthenticated request, or set the JWT claims manually to simulate an authenticated user:

-- Simulate an authenticated user with a specific clerk_id
SET LOCAL request.jwt.claims TO '{"sub": "user_abc123"}';
SET LOCAL role TO authenticated;

-- This should return only that user's row
SELECT * FROM users;

Run this against your policies before going to production. If you see rows you should not see, the policy has a gap. If you see no rows when you should see some, the policy is too restrictive. Either way, catching it in the SQL editor is much better than discovering it from a user report.


The Supabase schema in GetLaunchpad ships with RLS enabled on every table, policies written for the users and subscriptions tables, and all three client tiers pre-configured — browser, server, and admin. The hard part is already done; you just need to build your product on top of it.

More articles