Row Level Security (RLS) is one of Supabase's most powerful features β and one of the most misunderstood. If you've ever written a Supabase policy that works in the dashboard but fails silently in your Next.js app, this post is for you.
I'm building Wishyze, an AI-powered affirmation platform. We run on Next.js 14, Supabase (Auth + Postgres), and have around 28,000 users. RLS is the backbone of our data model β every user's rituals, streaks, and preferences are isolated at the database level. Here's everything I learned the hard way.
Why RLS Matters (Even for Solo Devs)
The pitch is simple: instead of remembering to add userId checks in every API route and every query, you write policies once in Postgres and the database enforces access control automatically.
Sounds great. In practice, there are three things that trip people up:
- RLS is disabled by default on new Supabase tables
- The anon key bypasses RLS unless you're authenticated
-
Server-side vs client-side clients behave differently with
auth.uid()
Let's fix all three.
Step 1: Enable RLS on Every Table
This is the most common mistake. You create a table, write policies, and nothing happens because RLS was never turned on.
ALTER TABLE rituals ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE streak_data ENABLE ROW LEVEL SECURITY;
In the Supabase dashboard, there's a toggle for this. Use it.
Step 2: Write Policies That Actually Work
Here's a typical pattern for a user's own data:
-- Users can only see their own rituals
CREATE POLICY "Users can view own rituals"
ON rituals
FOR SELECT
USING (auth.uid() = user_id);
-- Users can insert their own rituals
CREATE POLICY "Users can create own rituals"
ON rituals
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can update their own rituals
CREATE POLICY "Users can update own rituals"
ON rituals
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
The key distinction is USING vs WITH CHECK:
- USING determines which rows you can see (SELECT, UPDATE, DELETE)
- WITH CHECK determines what values you can write (INSERT, UPDATE)
For most apps where users own their data, USING and WITH CHECK are identical.
Step 3: The Server Client Pattern
Here's where Next.js App Router and Supabase get interesting. You need two different Supabase clients:
// app/lib/supabase/server.ts
import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
export async function createServerClient() {
const cookieStore = await cookies();
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component β ignore
}
},
},
}
);
}
This client reads the user's session from cookies. When you call supabase.auth.getUser() through this client, it verifies the JWT against your Supabase project and auth.uid() in your RLS policies resolves correctly.
// app/api/rituals/route.ts
import { createServerClient } from "@/app/lib/supabase/server";
import { NextResponse } from "next/server";
export async function GET() {
const supabase = await createServerClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// RLS handles the rest β only this user's rituals come back
const { data, error: queryError } = await supabase
.from("rituals")
.select("*")
.order("created_at", { ascending: false });
if (queryError) {
return NextResponse.json({ error: queryError.message }, { status: 500 });
}
return NextResponse.json({ rituals: data });
}
Notice what we're not doing: there's no WHERE user_id = auth.uid() in the query. The RLS policy handles that automatically. This is the whole point.
Step 4: The Service Role Client (Use Carefully)
Sometimes you need to bypass RLS β admin operations, background jobs, webhooks. That's what the service role key is for:
// app/lib/supabase/admin.ts
import { createClient } from "@supabase/supabase-js";
// NEVER expose this key to the client
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
}
Rules I follow:
- This file lives only in
app/lib/supabase/β never imported by any client component - We use it exclusively in
app/api/routes that handle Paddle webhooks or background syncs - Every usage has a comment explaining why RLS bypass is needed
Common Gotchas I've Hit in Production
"My query returns everything"
RLS is off. Double-check:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = public;
If rowsecurity is f, you need to ENABLE ROW LEVEL SECURITY on that table.
"auth.uid() returns null in my API route"
You're probably using the anon client instead of the server client. The server client reads cookies and resolves the JWT. The anon client doesn't have access to the user's session unless you explicitly pass a Bearer token.
"RLS works in development but breaks in production"
Check your RLS policies use auth.uid(), not auth.jwt()->>sub. The former works with both JWT verification methods. The latter can fail if your Supabase project uses a different JWT secret than expected.
"My Supabase realtime subscription shows no data"
Realtime is also affected by RLS policies. Make sure your SELECT policy allows the authenticated user to see the rows you're trying to subscribe to.
The Policy Patterns We Use in Production
After 28,000 users and a year of iteration, here's what our policy layer looks like:
users table β Users can only access their own profile
rituals table β Users can CRUD their own rituals
streak_data table β Users can read/update their own streaks
premium_status β Service role only (managed by Paddle webhook)
For the premium_status table specifically, we use a policy that only allows the service role:
CREATE POLICY "Service role only for premium status"
ON premium_status
FOR ALL
USING (false);
The service role bypasses RLS entirely, so the webhook handler can insert/update freely. Regular users see nothing. This is cleaner than trying to write permissive policies and accidentally exposing payment data.
Key Takeaways
- Enable RLS on every table β don't rely on "I'll remember"
- Write INSERT, SELECT, and UPDATE policies separately β the defaults are permissive and confusing
-
Use the server client pattern β it's the only way
auth.uid()works in App Router API routes - Guard the service role key β treat it like a database root password
-
Test policies with
SET ROLEin SQL β simulate different users before deploying
RLS adds a small amount of overhead to each query, but the safety net is worth it. Once your policies are solid, you can refactor your frontend, add new API routes, or even open-source your Supabase project without worrying about data leaks.
If you're building something that needs per-user data isolation, give Supabase RLS a try. It's one of those features that feels like overkill until you need it β and then you can't imagine building without it.
If you want to see how we've put this pattern to use in production, check out wishyze.com.
United States
NORTH AMERICA
Related News
π I Built a Dropshipping Automation Pipeline β Here's What I Learned (and What I'd Do Differently)
10h ago
How I Cut My LLM API Bill by 40x: A Freelancer's Migration Story
10h ago

Mattress Firm Coupons: Save up to $600
3h ago
Google Ordered to Pay $2 Billion For Anti-Competitive Practices By Swedish Court
20h ago
The Censorship Wall: Why Every AI Companion App Ends Up Filtering You
20h ago