Next.js + Supabase is the most popular stack for indie hackers and small teams in 2026. It's fast to build with, generous on free tiers, and scales well.
But the defaults aren't secure. Here's what you need to fix.
1. Enable Row Level Security (RLS) on Every Table
Supabase exposes your database directly via its PostgREST API. Without RLS, anyone with your anon key can read and write any table.
-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Users can only read their own profile
CREATE POLICY "Users read own profile"
ON profiles FOR SELECT
USING (id = auth.uid());
-- Users can only update their own profile
CREATE POLICY "Users update own profile"
ON profiles FOR UPDATE
USING (id = auth.uid())
WITH CHECK (id = auth.uid());
Common mistake: Enabling RLS but forgetting the WITH CHECK clause on INSERT/UPDATE policies. Without it, a user could insert a row with someone else's user_id.
RLS Patterns for Common Scenarios
Beyond basic "users see their own data" policies, you'll need patterns for shared resources, team-based access, and public/private data:
-- Team-based access: users can read rows belonging to their team
CREATE POLICY "Team members read team data"
ON team_documents FOR SELECT
USING (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = auth.uid()
)
);
-- Public/private toggle: some rows are public, others are owner-only
CREATE POLICY "Public or own rows"
ON posts FOR SELECT
USING (
is_public = true OR user_id = auth.uid()
);
-- Admin bypass: allow admins to read all rows
CREATE POLICY "Admins read all"
ON orders FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid() AND role = 'admin'
)
);
Performance tip: RLS policies execute on every query. Avoid complex subqueries in policies for tables with high read volumes. Index the columns used in your policies (like user_id, team_id) for best performance.
For a deeper dive into RLS and other Supabase-specific hardening, see our Supabase security checklist.
2. Never Expose the Service Role Key
The Supabase service role key bypasses RLS entirely. It should only exist in server-side environment variables.
# .env.local (server-side only)
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# NEVER prefix with NEXT_PUBLIC_ — that exposes it to the browser
If you see NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY anywhere in your code, fix it immediately.
What Happens if the Service Role Key Leaks
If your service role key is exposed in client-side JavaScript, an attacker can:
- Read every row in every table, regardless of RLS policies
- Write arbitrary data to any table, including inserting fake users or modifying billing records
- Delete data from any table without restriction
- Call Supabase Auth Admin APIs to create, delete, or impersonate users
- Access Supabase Storage with full admin privileges
This is a total compromise. If you suspect a leak, rotate the key immediately in Supabase Dashboard > Settings > API and redeploy all server-side services.
3. Protect API Routes with Auth Middleware
Every API route that handles user data needs authentication:
import { createClient } from '@/lib/supabase/server';
export async function GET(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Always scope queries to the authenticated user
const { data } = await supabase
.from('projects')
.select('*')
.eq('user_id', user.id);
return NextResponse.json(data);
}
Common mistake: Using getSession() instead of getUser(). Sessions can be spoofed from the client. getUser() makes a fresh request to Supabase Auth and is the only reliable way to verify identity.
4. Session Management with Supabase Auth
Supabase Auth uses PKCE (Proof Key for Code Exchange) for the authentication flow in server-side rendering contexts. Understanding how sessions work helps you avoid common pitfalls.
Cookie Chunking
Supabase stores the session JWT in cookies. Because JWTs can exceed the 4KB browser cookie limit, the Supabase SSR library chunks the token across multiple cookies:
sb-<project-ref>-auth-token.0 = <first 3800 chars>
sb-<project-ref>-auth-token.1 = <remaining chars>
This means you cannot simply read req.cookies.get('sb-xxx-auth-token') — you need the official @supabase/ssr helpers, which handle reassembly automatically.
Server-Side Session Refresh
Sessions expire. Your server-side Supabase client must be configured to refresh tokens:
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() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
}
);
}
Key point: The setAll callback is required for token refresh to persist. If you only implement getAll, expired sessions will never be renewed and users will be silently logged out.
5. Add CSRF Protection
Next.js API routes don't include CSRF protection by default. For session-based auth, validate the Origin header:
function checkCsrf(req: NextRequest): NextResponse | null {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return null;
const origin = req.headers.get('origin');
const allowed = ['https://yourdomain.com'];
if (!origin || !allowed.includes(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return null;
}
Applying CSRF to All Mutating Routes
Don't add CSRF checks ad-hoc — create a wrapper or apply it consistently to every POST, PUT, PATCH, and DELETE handler:
// lib/csrf.ts
const ALLOWED_ORIGINS = [
process.env.NEXT_PUBLIC_SITE_URL,
// Add staging URLs if needed
].filter(Boolean);
export function validateCsrf(req: NextRequest): NextResponse | null {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return null;
const origin = req.headers.get('origin');
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return null;
}
Important caveat: If you support API key authentication (e.g., Bearer cvd_live_...), those requests won't have an Origin header. You need to skip CSRF checks for API-key-authenticated requests while still enforcing them for cookie-based sessions.
For a comprehensive CSRF guide, see our CSRF protection guide.
6. Validate and Sanitize All Input
Never trust data from the client:
// Validate types
if (typeof name !== 'string' || name.length > 100) {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
}
// Validate UUIDs
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(projectId)) {
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
}
// Validate enums against allowlist
const VALID_TYPES = ['supabase', 'firebase', 'convex'];
if (backendType && !VALID_TYPES.includes(backendType)) {
return NextResponse.json({ error: 'Invalid backend type' }, { status: 400 });
}
Schema Validation with Zod
For complex inputs, use a schema validation library like Zod instead of manual checks:
import { z } from 'zod';
const ProjectSchema = z.object({
name: z.string().min(1).max(100),
url: z.string().url(),
backendType: z.enum(['supabase', 'firebase', 'convex']).optional(),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const result = ProjectSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Validation failed', details: result.error.flatten() },
{ status: 400 }
);
}
// result.data is now typed and validated
const { name, url, backendType } = result.data;
// ... proceed safely
}
7. Set Security Headers
Add these in your next.config.ts or middleware:
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
];
Content Security Policy (CSP)
CSP is the most important security header, and also the trickiest to configure with Next.js + Supabase. You need to allow connections to Supabase while blocking everything else:
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js needs these for hydration
`connect-src 'self' https://*.supabase.co https://api.stripe.com`,
"img-src 'self' data: blob: https:",
"style-src 'self' 'unsafe-inline'",
"frame-ancestors 'none'",
].join('; ');
Warning: Never use 'strict-dynamic' in CSP without a nonce implementation. In CSP Level 3 browsers, 'strict-dynamic' nullifies both 'unsafe-inline' and 'self', which breaks all client-side JavaScript in Next.js.
For a complete guide on all security headers, see our security headers guide.
8. Encrypt Sensitive Data at Rest
If you store PATs, API keys, or tokens in your database, encrypt them:
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
function encrypt(text: string): string {
const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `enc:${Buffer.concat([iv, tag, encrypted]).toString('base64')}`;
}
When to Use Encryption
Not everything needs application-level encryption. Use it for:
- Third-party API tokens stored on behalf of users (e.g., a Supabase PAT, GitHub token)
- Webhook signing secrets that users configure
- OAuth refresh tokens that grant ongoing access to external services
You do NOT need application-level encryption for:
- Passwords — use bcrypt/argon2 hashing instead (one-way, not reversible)
- Session tokens — Supabase Auth handles these
- Data that users need to search on — you can't run WHERE clauses on encrypted columns
Key Management
Your ENCRYPTION_KEY is the crown jewel. If it leaks, all encrypted data is compromised. Follow these practices:
# Generate a 256-bit key
openssl rand -hex 32
# Store it ONLY in environment variables — never in code or config files
# Rotate periodically: encrypt new data with new key, re-encrypt old data in a migration
Use a prefix like enc: to distinguish encrypted values from legacy plaintext. This lets your decryption function handle both gracefully during migration:
function decrypt(value: string): string {
if (!value.startsWith('enc:')) {
return value; // Legacy plaintext — handle gracefully
}
const data = Buffer.from(value.slice(4), 'base64');
const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
const iv = data.subarray(0, 12);
const tag = data.subarray(12, 28);
const encrypted = data.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted) + decipher.final('utf8');
}
9. Supabase Storage Security
If your app uses Supabase Storage for file uploads, it needs its own security layer.
Bucket Policies
Create separate buckets for public and private files. Never mix them:
-- Public bucket for avatars (anyone can read, only owner can write)
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);
CREATE POLICY "Anyone can view avatars"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
CREATE POLICY "Users upload their own avatar"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
Signed URLs for Private Files
For private files (invoices, exports, reports), use signed URLs with short expiration:
const { data } = await supabase.storage
.from('private-reports')
.createSignedUrl('report-123.pdf', 300); // 5-minute expiry
// data.signedUrl — give this to the user, it expires automatically
Never serve private files through a public bucket. Even if the file path is "random," a determined attacker can enumerate or guess paths.
10. Database Function Security
Custom database functions (RPCs) in Supabase need careful security configuration.
Set search_path Explicitly
By default, Postgres functions can be tricked into using a malicious schema if search_path is not locked down:
CREATE OR REPLACE FUNCTION check_project_limit(p_user_id UUID)
RETURNS JSON
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp -- Always set this explicitly
AS $$
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'allowed', (count(*) < 5),
'current_count', count(*)
) INTO result
FROM projects
WHERE user_id = p_user_id;
RETURN result;
END;
$$;
SECURITY DEFINER vs SECURITY INVOKER
- SECURITY INVOKER (default): Function runs with the caller's permissions. RLS policies apply normally.
- SECURITY DEFINER: Function runs with the owner's permissions (typically the superuser). RLS is bypassed.
Use SECURITY DEFINER only when the function needs to perform actions the caller shouldn't be able to do directly (like incrementing a counter in a table the user can't write to). Always pair it with explicit input validation inside the function, and revoke EXECUTE from roles that shouldn't use it:
-- Only allow service_role to call this function
REVOKE EXECUTE ON FUNCTION check_project_limit FROM authenticated, anon;
11. Server Actions Security in Next.js 15+
Next.js Server Actions (the 'use server' directive) create API endpoints automatically. Every exported async function in a 'use server' file becomes a publicly callable endpoint.
'use server';
import { createClient } from '@/lib/supabase/server';
export async function deleteProject(projectId: string) {
// ALWAYS validate auth — this is a public endpoint
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Unauthorized');
// ALWAYS validate input
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(projectId)) throw new Error('Invalid ID');
// ALWAYS scope to the authenticated user
const { error } = await supabase
.from('projects')
.delete()
.eq('id', projectId)
.eq('user_id', user.id); // Prevents deleting other users' projects
if (error) throw new Error('Delete failed');
}
Key rule: Treat every Server Action like a public API route. Never assume that just because it's called from your UI, it's safe — anyone can invoke it directly with a POST request.
12. Edge Function Security Patterns
If you use Supabase Edge Functions (Deno), apply these patterns:
Authenticate Requests with a Shared Secret
// supabase/functions/my-function/index.ts
Deno.serve(async (req) => {
const scannerKey = req.headers.get('x-scanner-key');
if (scannerKey !== Deno.env.get('SCANNER_SECRET_KEY')) {
return new Response('Unauthorized', { status: 401 });
}
// ... proceed with trusted request
});
Restrict CORS to Your Domain
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') ?? 'https://yourdomain.com';
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
'Access-Control-Allow-Headers': 'authorization, x-scanner-key, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
// Handle preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
Add Timeouts to External Requests
Edge Functions have a default execution timeout, but individual fetch calls don't. Always add AbortController timeouts:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000); // 15 seconds
try {
const res = await fetch(targetUrl, { signal: controller.signal });
// ... process response
} catch (err) {
if (err.name === 'AbortError') {
return new Response('Upstream timeout', { status: 504 });
}
throw err;
} finally {
clearTimeout(timeout);
}
13. Prevent SSRF on User-Provided URLs
If your app fetches URLs from user input (webhooks, preview images, scan targets), validate them:
function isPrivateIp(hostname: string): boolean {
// Block localhost, private networks, and cloud metadata endpoints
const blocked = ['localhost', '127.0.0.1', '169.254.169.254', '0.0.0.0'];
return blocked.includes(hostname) || hostname.endsWith('.local') || hostname.endsWith('.internal');
}
DNS Rebinding Protection
Simple hostname checks aren't enough. An attacker can set up a DNS record that first resolves to a public IP (passing your check) and then resolves to 127.0.0.1 (hitting your internal network). The fix is to resolve the hostname yourself and validate the resolved IP:
import { resolve4 } from 'dns/promises';
async function resolveAndValidateUrl(url: string): Promise<string> {
const parsed = new URL(url);
const ips = await resolve4(parsed.hostname);
for (const ip of ips) {
if (isPrivateIp(ip)) {
throw new Error('URL resolves to a private IP address');
}
}
// Replace hostname with resolved IP to prevent rebinding
parsed.hostname = ips[0];
return parsed.toString();
}
Quick Security Audit
Before your next deploy, check:
- [ ] RLS enabled on all tables with proper policies
- [ ] Service role key is server-side only
- [ ] All API routes verify auth via
getUser() - [ ] CSRF protection on mutating endpoints
- [ ] Input validation on all user-provided data
- [ ] Security headers configured (including CSP)
- [ ] Sensitive data encrypted at rest
- [ ] SSRF protection on URL-fetching features
- [ ] Storage buckets have proper access policies
- [ ] Database functions use explicit
search_path - [ ] Server Actions validate auth and input
- [ ] Edge Functions authenticate callers and restrict CORS
Or run an automated scan — tools like CheckVibe check all of the above (and 30 more categories) in under a minute.
For more Next.js-specific security patterns, see our Next.js security best practices guide.
FAQ
What's the difference between getSession and getUser?
getSession() reads the session from the local cookie or cache without verifying it against the server. This means a malicious client can forge or tamper with the session data. getUser() makes a fresh API call to Supabase Auth, which validates the JWT signature and checks that the user still exists and is not banned. Always use getUser() on the server side when you need to verify identity for authorization decisions. Use getSession() only on the client side for non-sensitive UI display (like showing the user's name).
Should I use Server Components or Route Handlers for data fetching?
Both are valid, but they have different security profiles. Server Components run only on the server and never expose their logic to the client — they are inherently safer for data fetching. Route Handlers (API routes) create public HTTP endpoints that anyone can call, so they need explicit auth checks, CSRF protection, and input validation. Use Server Components for page-level data loading where the result is rendered as HTML. Use Route Handlers when you need a true API (for client-side fetches, webhooks, or external integrations).
How do I secure Supabase Edge Functions?
Supabase Edge Functions are deployed with --no-verify-jwt by default in many setups, meaning they accept unauthenticated requests. Secure them by: (1) requiring an x-scanner-key or Authorization header with a shared secret, (2) restricting CORS to your domain using an ALLOWED_ORIGIN environment variable, (3) adding timeouts to all external fetch calls with AbortController, and (4) validating all input parameters before processing. If the Edge Function accesses your database, use the service role key server-side but never return raw database errors to the caller.
Is Supabase RLS enough for security?
RLS is a critical layer, but it's not sufficient on its own. RLS protects data at the database level, which is excellent — but you still need: authentication checks in your API routes (to return proper 401/403 responses instead of empty arrays), input validation (RLS won't stop malformed data from being inserted), rate limiting (RLS won't prevent a user from making 10,000 requests per second to their own data), and CSRF protection (RLS doesn't know if the request was initiated by your app or an attacker's page). Think of RLS as your last line of defense, not your only one.
How do I handle auth in Next.js middleware?
Next.js middleware runs on the Edge Runtime before your routes execute. You can use it to redirect unauthenticated users, but be careful: middleware should only check for the existence of a session cookie, not validate it. Full JWT validation requires crypto APIs that may not be available in the Edge Runtime. Use middleware for redirects (e.g., redirect /dashboard to /login if no session cookie exists), but always re-verify the user with getUser() in your actual Route Handlers and Server Components. Never use middleware as your sole auth gate.
Building with Next.js and Supabase? Make sure it's locked down. Scan your app with CheckVibe and catch misconfigurations in under a minute.