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.
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.
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.
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.
If your service role key is exposed in client-side JavaScript, an attacker can:
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.
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.
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.
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.
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.
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;
}
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.
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 });
}
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
}
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=()' },
];
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.
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')}`;
}
Not everything needs application-level encryption. Use it for:
You do NOT need application-level encryption for:
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');
}
If your app uses Supabase Storage for file uploads, it needs its own security layer.
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]
);
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.
Custom database functions (RPCs) in Supabase need careful security configuration.
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;
$$;
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;
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.
If you use Supabase Edge Functions (Deno), apply these patterns:
// 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
});
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 });
}
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);
}
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');
}
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();
}
Before your next deploy, check:
getUser()search_pathOr 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.
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).
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).
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.
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.
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.
Paste your URL and get a security report in 30 seconds. 100+ automated checks with AI-powered fix prompts.
Scan your site freeRelated articles
Step-by-step security checklist for Next.js apps with Supabase. Covers RLS policies, API key exposure, auth hardening, security headers, and common mistakes.
Next.js apps are fast to build but easy to misconfigure. Here are 10 specific security issues most developers miss, with code examples for each vulnerability and its fix.
A practical guide to detecting cross-site scripting (XSS) vulnerabilities in your web application. Learn the three types of XSS and how automated tools catch them.