Row Level Security (RLS) is a PostgreSQL feature that restricts which rows a database user can access. Supabase enables RLS by default on new tables and encourages writing policies for every table.
Kit takes a different approach — it uses application-layer security rather than database-level RLS. This page explains why, how it works, and when you might want to add RLS policies on top.
Kit's Security Model
Kit secures database access at the application layer using three mechanisms:
- Clerk authentication verifies the user's identity on every request via middleware
- Explicit userId filtering ensures every query only returns data belonging to the authenticated user
- The repository pattern enforces these access patterns consistently across the codebase
How It Works in Practice
Every database query in Kit includes a
userId filter. There is no way for application code to accidentally return another user's data because the query functions require a user ID as a parameter:typescript
// src/lib/db/queries/files.ts — Ownership check is built into the function signature
export async function getUserFileById(
id: string,
userId: string // Caller must provide the authenticated user's ID
): Promise<File | null> {
return prisma.file.findFirst({
where: {
id,
userId, // Only returns the file if it belongs to this user
},
})
}
The calling code in API routes and Server Actions always passes the authenticated user's ID from Clerk:
typescript
// In an API route or Server Action
const { userId } = await getServerAuth()
if (!userId) throw new Error('Unauthorized')
const file = await getUserFileById(fileId, userId)
if (!file) throw new Error('Not found') // Also covers unauthorized access
This pattern repeats across the entire codebase — subscription queries filter by
userId, credit transactions filter by userId, AI conversations filter by userId. The security boundary is in the TypeScript code, not in SQL policies.Why Application-Layer Security?
Kit chose this approach for three practical reasons:
1. Prisma connects with the service role key. Prisma uses the
DATABASE_URL connection string, which authenticates as the database owner (or a privileged role on Supabase). This role bypasses all RLS policies by default. Adding RLS policies to tables would not protect against a bug in the application code because Prisma's connection has full access regardless.2. Type safety catches errors at compile time. Every query function has a
userId: string parameter in its TypeScript signature. If you forget to pass it, the compiler catches it before the code runs. This is a stronger guarantee than RLS, which only fails at runtime.3. Simpler to reason about. The security logic lives next to the business logic in TypeScript, not in a separate SQL layer. When you read an API route, you can see exactly what data it accesses without cross-referencing database policies.
This is a deliberate architectural choice, not an oversight. Many production SaaS applications use application-layer security exclusively. Database-level RLS adds value in specific scenarios — see below.
When to Add RLS
There are scenarios where adding Supabase RLS policies provides meaningful additional security:
Direct Supabase Client Access
If you use the Supabase JavaScript client (not Prisma) to query data — for example, in Supabase Realtime subscriptions or client-side queries — those requests authenticate with the anon key and are subject to RLS policies. Without policies, these queries would be blocked entirely (Supabase enables RLS by default).
typescript
// This query uses the Supabase JS client, NOT Prisma
// It respects RLS policies and uses the anon key
import { supabase } from '@/lib/db/supabase'
const { data } = await supabase
.from('File')
.select('*')
.eq('userId', userId)
Multi-Tenant Applications
If you extend Kit into a multi-tenant application where different organizations share the same database, RLS provides a safety net. Even if application code has a bug, RLS prevents data from leaking across tenant boundaries.
Defense in Depth
For highly sensitive data (financial records, health data, PII), adding RLS as a second layer of protection follows the defense-in-depth security principle. If a code bug bypasses the application-layer check, the database still blocks unauthorized access.
Regulatory Requirements
Some compliance frameworks (SOC 2, HIPAA) may require database-level access controls in addition to application-level checks. RLS policies provide auditable evidence of data isolation.
Adding RLS Policies
If you decide to add RLS, here is how to set it up for Kit's tables.
Remember that Prisma connects with a privileged role that bypasses RLS. These policies only affect queries made through the Supabase JavaScript client (with the
anon or authenticated key), Supabase Realtime, or Supabase Edge Functions. Your existing Prisma-based application code is unaffected.Step-by-Step Setup
1
Enable RLS on the table
RLS is enabled by default on Supabase, but if you created tables via Prisma migrations, you may need to enable it explicitly:
sql
-- Run in Supabase SQL Editor
ALTER TABLE "File" ENABLE ROW LEVEL SECURITY;
2
Create a helper function for auth
Create a function that extracts the user ID from the Supabase JWT token. This is used in all policies:
sql
-- Extract the Clerk user ID from the Supabase auth JWT
-- The user ID is stored in the 'sub' claim
CREATE OR REPLACE FUNCTION auth.clerk_user_id()
RETURNS TEXT AS $$
SELECT nullif(
current_setting('request.jwt.claims', true)::json->>'sub',
''
)
$$ LANGUAGE sql STABLE;
Kit uses Clerk for authentication, not Supabase Auth. If you want RLS policies to work with Clerk tokens, you need to configure Supabase to accept Clerk JWTs. See the Clerk + Supabase integration docs for setup instructions.
3
Write SELECT policies
Allow users to read only their own data:
sql
-- Users can only read their own files
CREATE POLICY "Users can view own files"
ON "File"
FOR SELECT
USING ("userId" = auth.clerk_user_id());
-- Users can only read their own subscriptions
CREATE POLICY "Users can view own subscription"
ON "Subscription"
FOR SELECT
USING ("userId" = (
SELECT id FROM "User" WHERE "clerkId" = auth.clerk_user_id()
));
-- Users can only read their own credit transactions
CREATE POLICY "Users can view own transactions"
ON "CreditTransaction"
FOR SELECT
USING ("userId" = (
SELECT id FROM "User" WHERE "clerkId" = auth.clerk_user_id()
));
4
Write INSERT/UPDATE/DELETE policies
Control write access:
sql
-- Users can only create files for themselves
CREATE POLICY "Users can upload own files"
ON "File"
FOR INSERT
WITH CHECK ("userId" = (
SELECT id FROM "User" WHERE "clerkId" = auth.clerk_user_id()
));
-- Users can only delete their own files
CREATE POLICY "Users can delete own files"
ON "File"
FOR DELETE
USING ("userId" = (
SELECT id FROM "User" WHERE "clerkId" = auth.clerk_user_id()
));
5
Test the policies
Use the Supabase SQL Editor to verify. Switch to the
anon role and test:sql
-- Set the JWT claims to simulate an authenticated user
SET request.jwt.claims = '{"sub": "user_test_clerk_id"}';
-- This should only return files for the simulated user
SET ROLE anon;
SELECT * FROM "File";
RESET ROLE;
Policy Examples for All Tables
Combining RLS with Prisma
If you add RLS policies, be aware of how different connection methods interact with them:
| Connection | Role | RLS Applied? | Use Case |
|---|---|---|---|
Prisma (DATABASE_URL) | Owner / service role | No — bypasses RLS | Server-side queries, API routes, Server Actions |
Supabase JS (anon key) | anon role | Yes | Client-side queries, Realtime subscriptions |
Supabase JS (service_role key) | Service role | No — bypasses RLS | Server-side admin operations |
| Supabase Edge Functions | Depends on key used | Depends | Serverless functions |
Using the Anon Key for RLS-Enforced Queries
If you need Prisma-like queries that respect RLS, use the Supabase JavaScript client with the
anon key:typescript
import { createClient } from '@supabase/supabase-js'
// Client with anon key — RLS policies are enforced
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// This query is filtered by RLS — only returns the user's own files
const { data: files } = await supabase
.from('File')
.select('*')
When to Use Each
| Scenario | Use Prisma | Use Supabase JS (anon) |
|---|---|---|
| Server Components | Yes | No |
| API Routes | Yes | No |
| Server Actions | Yes | No |
| Client-side queries | No | Yes |
| Realtime subscriptions | No | Yes |
| Admin operations | Yes | No |
In practice, most Kit applications only need Prisma for database access. The Supabase JS client is primarily used for Supabase Storage (file uploads) and Supabase Realtime (live updates). If you are not using these features, you may not need RLS policies at all.
Key Takeaways
- Kit uses application-layer security by default — Clerk authentication + explicit userId filtering in every query function.
- Prisma bypasses RLS — Adding policies does not change the behavior of your existing server-side code.
- Add RLS when you use the Supabase JS client directly, build multi-tenant features, or need defense-in-depth for compliance.
- RLS is optional — The application-layer approach with TypeScript type safety is a valid and widely-used security model.