Next.js + Supabase Security
5 critical security patterns for the most popular vibe coding stack
Next.js + Supabase apps need five security patterns: enable RLS on every table, use policies for each operation, protect service keys, validate in Server Components, and secure Server Actions. AI tools often skip these - especially Row Level Security - leaving your database exposed.
Why does this stack need special attention?
Next.js + Supabase is the most popular stack for vibe coding full-stack apps. It's fast to set up, has great DX, and AI tools like Cursor, Bolt, and Claude Code generate working code quickly.
But there's a critical difference from traditional stacks: Supabase exposes your database directly via API. Your frontend talks directly to Postgres through Supabase's REST API. The ONLY thing protecting your data is Row Level Security (RLS).
According to the OWASP API Security Top 10, Broken Object Level Authorization (BOLA) has been the #1 API security risk since 2019. In Supabase terms, that means missing or misconfigured RLS policies. AI-generated vibe code almost never sets this up correctly.
What is Row Level Security?
Row Level Security (RLS) is a database-level firewall that checks every query against a policy you define. Think of it like hotel room keys: your key only opens YOUR room, even if you know other room numbers exist.
Without RLS, anyone with your Supabase anon key (which is public in your frontend code) can query any row in any table. With RLS enabled and proper policies, each user can only access their own data - even if they try to manipulate queries.
According to Supabase documentation, RLS must be enabled on ALL tables in the public schema. Tables without RLS are accessible to anyone with the anon key.
What are the 5 critical security patterns?
These patterns address the most common security issues in vibe coded Next.js + Supabase apps:
Missing Row Level Security (RLS)
CriticalAI tools create database tables without enabling RLS. Without RLS, anyone with your Supabase anon key can read, write, and delete ALL data in your tables. Reference: CWE-639
-- AI generates tables without RLS
CREATE TABLE profiles (
id UUID PRIMARY KEY,
user_id UUID REFERENCES auth.users(id),
full_name TEXT,
avatar_url TEXT
);
-- No RLS = anyone can read/write all profiles-- Enable RLS and add policies
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own profile"
ON profiles FOR SELECT
TO authenticated
USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "Users can update their own profile"
ON profiles FOR UPDATE
TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);Overly Permissive RLS Policies
HighWhen AI tools do add RLS, they often create overly permissive policies with USING (true) that allow any authenticated user to access any row. Reference: CWE-862
-- AI might generate this for "allow authenticated users"
CREATE POLICY "Authenticated users can do anything"
ON profiles FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- Any logged-in user can access ANY profile-- Separate policies with proper ownership checks
CREATE POLICY "Users view own profile"
ON profiles FOR SELECT
TO authenticated
USING ((SELECT auth.uid()) = user_id);
CREATE POLICY "Users insert own profile"
ON profiles FOR INSERT
TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "Users update own profile"
ON profiles FOR UPDATE
TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
CREATE POLICY "Users delete own profile"
ON profiles FOR DELETE
TO authenticated
USING ((SELECT auth.uid()) = user_id);Service Key Exposed to Client
CriticalAI tools sometimes use the service_role key in client-side code. This key bypasses ALL RLS policies, giving full database access to anyone who views your source. Reference: CWE-798
// VULNERABLE: Service key in client component
// components/AdminPanel.tsx (Client Component)
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY! // EXPOSED!
)
// Anyone can see this key in browser devtools// lib/supabase/client.ts (for client components)
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // Safe with RLS
)
// lib/supabase/server.ts (for server only)
import { createClient } from '@supabase/supabase-js'
import 'server-only' // Prevents accidental client import
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!, // No NEXT_PUBLIC_ prefix
process.env.SUPABASE_SERVICE_KEY! // Never exposed
)Missing Auth in Server Actions
HighServer Actions are callable from the client. AI tools generate actions without verifying the user is authenticated or authorized to perform the operation. Reference: CWE-306
// VULNERABLE: Server Action without auth
'use server'
export async function deletePost(postId: string) {
// AI generates this - no auth check!
await supabase.from('posts').delete().eq('id', postId)
}
// Anyone can delete any post by calling this action// SECURE: Always verify auth in Server Actions
'use server'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function deletePost(postId: string) {
const cookieStore = cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { get: (name) => cookieStore.get(name)?.value } }
)
// Verify user is authenticated
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// Verify user owns this post (defense in depth)
const { data: post } = await supabase
.from('posts')
.select('user_id')
.eq('id', postId)
.single()
if (post?.user_id !== user.id) throw new Error('Not authorized')
await supabase.from('posts').delete().eq('id', postId)
}Secrets Leaked via Props
HighServer Components can access secrets, but props passed to Client Components are serialized and visible in the browser. AI tools often pass sensitive data directly. Reference: CWE-200
// VULNERABLE: Passing secrets to Client Component
// app/page.tsx (Server Component)
import ClientDashboard from './ClientDashboard'
export default async function Page() {
const config = {
apiKey: process.env.STRIPE_SECRET_KEY, // Will be exposed!
dbUrl: process.env.DATABASE_URL
}
return <ClientDashboard config={config} />
}
// Secrets visible in browser devtools/source// SECURE: Fetch data on server, pass only public data
// app/page.tsx (Server Component)
import ClientDashboard from './ClientDashboard'
import { getPublicDashboardData } from '@/lib/data'
export default async function Page() {
// Fetch data on server using secrets
const dashboardData = await getPublicDashboardData()
// Only pass sanitized, public data to client
return <ClientDashboard data={dashboardData} />
}
// lib/data.ts
import 'server-only'
export async function getPublicDashboardData() {
// Use secrets here, return only public data
const response = await fetch(process.env.ANALYTICS_API_URL!, {
headers: { 'Authorization': `Bearer ${process.env.ANALYTICS_KEY}` }
})
const data = await response.json()
return { visitCount: data.visits, pageViews: data.pageViews }
}AI fix prompt for Next.js + Supabase security
Copy this prompt and paste it into Cursor, Claude Code, or Bolt to automatically find and fix security vulnerabilities in your vibe coded project:
Review my Next.js + Supabase codebase for these security issues and fix them: ## 1. Row Level Security (RLS) Audit - Check all tables in supabase/migrations/*.sql for `ENABLE ROW LEVEL SECURITY` - For any table without RLS, add: `ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;` - Check for overly permissive policies with `USING (true)` or `WITH CHECK (true)` - Replace with ownership checks: `USING ((SELECT auth.uid()) = user_id)` ## 2. Key Exposure Check - Search for `SUPABASE_SERVICE` or `service_role` in client-side files - Ensure service key is ONLY in files with `import 'server-only'` - Verify no secrets use `NEXT_PUBLIC_` prefix (except SUPABASE_URL and SUPABASE_ANON_KEY) ## 3. Server Action Security - Check all files with `'use server'` directive - Ensure each action calls `supabase.auth.getUser()` before mutations - Add authorization checks (verify user owns the resource) - Validate all input parameters ## 4. Server Component Props - Search for `process.env.` in Server Components that pass props to Client Components - Move secret usage into separate server-only data fetching functions - Add `import 'server-only'` to files that access secrets ## 5. Create Missing RLS Policies For each table, create policies for SELECT, INSERT, UPDATE, DELETE: ```sql CREATE POLICY "Users can view own rows" ON table_name FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id); ``` After fixing, verify with Supabase Dashboard > Database > Security Advisor. List all files you modified with before/after snippets.
After running this prompt, verify fixes using the Supabase Security Advisor in your dashboard.
5-minute security audit checklist
Use this checklist before deploying your vibe coded Next.js + Supabase app to production:
RLS
Keys
Actions
Components
For automated detection, use Supabase's Production Checklist and run Vibeship Scanner to catch issues in your codebase.
Scan your Next.js + Supabase app
Find RLS issues, exposed keys, and missing auth checks automatically
Scan your code freeFrequently asked questions
Is Supabase anon key safe to expose?
Yes, but ONLY if Row Level Security (RLS) is enabled on every table. The anon key is designed to be public - it's meant for client-side code. RLS policies control what data the anon key can access. Without RLS, the anon key grants full read/write access to all tables.
How do I enable Row Level Security in Supabase?
Run ALTER TABLE your_table ENABLE ROW LEVEL SECURITY; for each table. Then create policies using CREATE POLICY. You can also enable RLS in the Supabase Dashboard under Table Editor > RLS. Remember: enabling RLS without policies blocks ALL access by default.
What's the difference between anon key and service role key?
The anon key respects RLS policies and is safe for client-side use. The service_role key BYPASSES all RLS policies and has full database access. Never expose the service role key to the client. Use it only in server-side code with import 'server-only'.
Do Server Components in Next.js protect my secrets?
Server Components can safely access secrets through environment variables without NEXT_PUBLIC_ prefix. However, any data you pass as props to Client Components gets serialized and sent to the browser. Use a Data Access Layer pattern and import 'server-only' to prevent leaks.
How do I secure Next.js API routes with Supabase?
Use the @supabase/ssr package with cookies() from next/headers to get the authenticated user. Always call supabase.auth.getUser() at the start of every route and return 401 if no user. Add RLS as a second layer of defense in case auth checks are bypassed.