Blog
nextjssupabasesecuritytutorial

Securing Next.js + Supabase Apps: A Practical Guide

CheckVibe Team
16 min read

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:

  1. Read every row in every table, regardless of RLS policies
  2. Write arbitrary data to any table, including inserting fake users or modifying billing records
  3. Delete data from any table without restriction
  4. Call Supabase Auth Admin APIs to create, delete, or impersonate users
  5. 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.

Is your app vulnerable?

Paste your URL and get a security report in 30 seconds. 100+ automated checks, AI-powered fix prompts for Cursor & Copilot.

Scan Your App Free