Back to blog
·7 min read

How to Secure Supabase Auth in Next.js: Skip the 10 Most Common Mistakes

Building with Supabase and Next.js? Most developers make the same auth mistakes in production that cost hours of debugging and expose user data. Here's exactly what to fix.

If you've built more than a few Next.js + Supabase applications, you've seen the pattern: everything works perfectly in development, then production breaks in ways that feel impossible to debug. Usually it's one of the same ten mistakes, repeated across projects.

The worst part? These aren't small bugs. They're security vulnerabilities, performance killers, and user-facing errors that crash your entire application. A developer working with AI code generation tools like Claude Code or Cursor is especially vulnerable here, because these tools generate code that works but doesn't account for the nuanced production-reality of auth systems.

This post covers the exact mistakes we see repeatedly, and how to fix them before they reach production.

The Row Level Security Setup That Everyone Gets Wrong

The single biggest vulnerability in Supabase applications is this: developers enable Supabase in their Next.js project, create tables, and RLS is disabled by default. Everything works. Data flows. Users authenticate. Then you deploy to production and realize anyone can read anyone else's data.

Here's what happens:

You create a profiles table for user data. You add the schema. You build auth. Everything passes local testing because you're the only user. But in production, RLS policies aren't enforced, so SELECT * against the profiles table returns every user's data.

The fix is straightforward but requires discipline:

Enable RLS on every table the moment you create it. In the Supabase dashboard, select your table, go to Auth Policies, and enable RLS. Then define explicit policies for each operation (SELECT, INSERT, UPDATE, DELETE).

Here's a minimal example for a profiles table:

```sql

CREATE POLICY "Users can read own profile"

ON profiles FOR SELECT

USING (auth.uid() = id);

CREATE POLICY "Users can update own profile"

ON profiles FOR UPDATE

USING (auth.uid() = id);

```

The key: use auth.uid() to reference the authenticated user. This ties every policy directly to the user session. If no user is authenticated, or the user ID doesn't match, the query fails silently (returns empty rows rather than an error, which is what you want).

But here's the mistake: developers build this locally where they're the only user, so they never test cross-user scenarios. Always test RLS policies with multiple user accounts before deploying. It's the difference between security and exposure.

Middleware Performance: The Silent Page Load Killer

Next.js middleware is powerful. Supabase documentation suggests using middleware to check auth status and redirect unauthenticated users. The pattern looks clean:

```javascript

import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';

import { NextResponse } from 'next/server';

export async function middleware(request) {

const res = NextResponse.next();

const supabase = createMiddlewareClient({ req: request, res });

const { data: { session } } = await supabase.auth.getSession();

if (!session) {

return NextResponse.redirect(new URL('/login', request.url));

}

return res;

}

export const config = {

matcher: ['/dashboard/:path*', '/profile/:path*']

};

```

This works. But it's also a performance trap.

Middleware runs on every request, including static assets, redirects, and prefetches. If your middleware calls supabase.auth.getSession(), you're making a database query on every single page load. For users with slow connections or high latency to your Supabase region, this adds 200-500ms to every page.

The better pattern: only check auth in middleware for protected routes, and keep the check lightweight. Use cookies to avoid extra lookups:

```javascript

import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';

import { NextResponse } from 'next/server';

export async function middleware(request) {

// Check for auth cookie first

const authToken = request.cookies.get('sb-auth-token');

if (!authToken && request.nextUrl.pathname.startsWith('/protected')) {

return NextResponse.redirect(new URL('/login', request.url));

}

return NextResponse.next();

}

```

For sensitive operations (API routes, server actions), verify the session server-side where it matters, not in middleware. This keeps your pages fast while maintaining security.

Handling AuthSessionMissingError: The Crash You Didn't See Coming

This error happens without warning: users navigate to your app, your middleware tries to refresh their session, something goes wrong with the Supabase connection, and suddenly every single page returns a 500 error. Users can't access anything, even public pages, because middleware crashed.

The fix is defensive error handling:

```javascript

import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs';

import { NextResponse } from 'next/server';

export async function middleware(request) {

try {

const res = NextResponse.next();

const supabase = createMiddlewareClient({ req: request, res });

try {

await supabase.auth.getSession();

} catch (error) {

// Session check failed, but don't crash

// Public pages should still work

if (request.nextUrl.pathname.startsWith('/protected')) {

return NextResponse.redirect(new URL('/login', request.url));

}

}

return res;

} catch (error) {

// If middleware itself crashes, allow the request through

// Better to serve the page than return 500

return NextResponse.next();

}

}

```

This ensures that if auth fails, users can still access public pages. Protected pages redirect to login, but the application stays up.

Environment Variables and Deployment Secrets

This is where AI-generated code often fails. When you generate a Next.js + Supabase scaffold with Claude Code or Cursor, the output usually includes placeholder environment variables:

```

NEXT_PUBLIC_SUPABASE_URL=your-project-url

NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

```

The mistake: developers copy these into .env.local, then commit the file to git. Anon keys are public (they're meant to be), but service role keys absolutely cannot be committed.

The fix is a two-step process:

First, never commit .env.local. Add it to .gitignore:

```

.env.local

.env.*.local

```

Second, for deployment, add secrets to your hosting platform (Vercel, Netlify, whatever). In Vercel, this means going to Settings > Environment Variables and adding each secret. Your CI/CD pipeline picks them up automatically.

Also, rotate your Supabase service role key regularly. If you ever commit it by accident, regenerate it immediately in the Supabase dashboard.

Server Actions vs. API Routes for Auth-Protected Operations

When you're building with AI assistance, you'll often see code that mixes auth checks across API routes, server actions, and client-side logic. This creates inconsistency and security gaps.

The cleaner pattern: use Next.js server actions for auth-protected operations, because they automatically have access to the request context:

```javascript

import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';

import { cookies } from 'next/headers';

export async function updateProfile(formData) {

'use server';

const cookieStore = cookies();

const supabase = createServerComponentClient({ cookies: () => cookieStore });

const { data: { session } } = await supabase.auth.getSession();

if (!session) {

throw new Error('Unauthorized');

}

const { error } = await supabase

.from('profiles')

.update({ name: formData.get('name') })

.eq('id', session.user.id);

if (error) throw error;

return { success: true };

}

```

Server actions are simpler, more secure (they don't expose your API surface), and they work seamlessly with forms. Use them by default for auth-protected operations.

Building This Right from the Start

The pattern that wins: enable RLS on every table, use lightweight middleware checks, handle auth errors gracefully, protect secrets in environment variables, and prefer server actions for protected operations.

If you're starting a new Supabase + Next.js project, these patterns should be scaffolded in from day one. That's exactly where tools like ZipBuild come in, generating production-ready project structures with these security patterns already baked in. Instead of learning these lessons the hard way across five projects, you start with a foundation that handles auth correctly.

The developers who avoid these mistakes aren't smarter. They just learned the pattern once and reuse it. Now you have too.

Try the free discovery chat at zipbuild.dev to see how production-ready scaffolding saves you from rebuilding these auth patterns every project.

Written by ZipBuild Team

Ready to build with structure?

Try the free discovery chat and see how ZipBuild architects your idea.

Start Building