Stack Guide Next.js Supabase

Next.js + Supabase Security

5 critical security patterns for the most popular vibe coding stack

Quick Answer

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.

RLS
Critical Feature
5
Key Patterns
#1 Stack
Vibe Coders
Copy-Paste
Fixes Included

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)

Critical

AI 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

VULNERABLE
-- 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
SECURE
-- 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);
Learn more about this vulnerability

Overly Permissive RLS Policies

High

When 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

VULNERABLE
-- 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
SECURE
-- 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);
Learn more about this vulnerability

Service Key Exposed to Client

Critical

AI 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
// 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
SECURE
// 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
)
Learn more about this vulnerability

Missing Auth in Server Actions

High

Server 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
// 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
// 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)
}
Learn more about this vulnerability

Secrets Leaked via Props

High

Server 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
// 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
// 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 }
}
Learn more about this vulnerability

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:

AI Security Fix Prompt
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 free

Frequently 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.

Related content