13 min read

How to add authentication to your Next.js + Supabase app (the real guide)

Most auth tutorials show you how to add a login button. This is the guide that shows you how to add auth that actually works — what to wire up, what AI tools get wrong, and the bugs you ship if you copy-paste the first Stack Overflow answer.

You're building a Next.js app on Supabase. You need users to sign in. You asked your AI assistant. It produced 200 lines of auth code that compile and run. The login button works.

Now you need to know whether the auth ACTUALLY works — whether the JWT is verified, whether sessions can be hijacked, whether your routes are protected by anything other than vibes.

This is the real guide. We'll wire up Supabase Auth in Next.js the right way, then look at the things AI tools get wrong, then test that everything actually holds up.

The 5-minute setup that works

If you just want the working pattern with no explanation, here it is.

npm install @supabase/supabase-js @supabase/ssr

```ts // lib/supabase/server.ts import "server-only"; 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: () => cookieStore.getAll(), setAll: (cookiesToSet) => { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ); } catch {} }, }, } ); } ```

```ts // lib/supabase/client.ts (no server-only) import { createBrowserClient } from "@supabase/ssr";

export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); } ```

```ts // middleware.ts import { type NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr";

export async function middleware(request: NextRequest) { let response = NextResponse.next({ request });

const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => request.cookies.getAll(), setAll: (cookiesToSet) => { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value) ); response = NextResponse.next({ request }); cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options) ); }, }, } );

// Refreshes the session — required to keep cookies fresh. const { data: { user } } = await supabase.auth.getUser();

// Protect everything under /app/* if (!user && request.nextUrl.pathname.startsWith("/app")) { const url = request.nextUrl.clone(); url.pathname = "/login"; return NextResponse.redirect(url); }

return response; }

export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico|api/health|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; ```

That's the working baseline. The rest of the post is what AI tools get wrong about it, and how to verify yours is right.

The 5 things AI tools get wrong about Next.js + Supabase auth

### Mistake 1 — putting the service-role key in client code

You ask Cursor for a Supabase client. Cursor produces:

// WRONG
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!  // <-- bypasses every RLS policy
);

This file gets imported by a client component. The service-role key ships in the JavaScript bundle. Anyone reading the bundle (every visitor's browser) has a key that bypasses every RLS policy you wrote.

The fix: anon key for client, service-role key wrapped behind import "server-only" so the bundler errors on accidental client imports. The 5-minute pattern above does this correctly.

### Mistake 2 — using getUser() only on the client

You write:

```tsx // WRONG "use client"; import { createClient } from "@/lib/supabase/client";

export default function Dashboard() { const supabase = createClient(); const [user, setUser] = useState(null); useEffect(() => { supabase.auth.getUser().then(({ data }) => setUser(data.user)); }, []); return <div>Welcome {user?.email}</div>; } ```

This shows the dashboard to whoever visits the page. The "are you logged in" check happens in JavaScript, AFTER the page loads. Server-side rendered HTML, the URL bar, browser history, and any reverse proxy can cache the dashboard for unauthenticated visitors.

The fix: check auth on the server BEFORE rendering. Either in middleware (above), in a layout that calls createClient() from server.ts and redirects if no user, or in the page itself as a Server Component.

### Mistake 3 — trusting the JWT without re-verifying

supabase.auth.getSession() returns the current session WITHOUT verifying it against Supabase's auth server. Critically: a stale or tampered session cookie can pass getSession() checks. Use getUser() instead — it makes a network call to Supabase to verify the session.

In the middleware code above, we use getUser() for this reason. AI tools sometimes generate getSession() because it's faster + the type signature is simpler. Faster is not safer here.

### Mistake 4 — RLS off on at least one table

Authentication is half the battle. The other half is authorization — what an authenticated user can read or write. Supabase enforces this via Row-Level Security (RLS) policies on each table.

Default state for a new Supabase table: RLS is OFF. Anyone with your anon key can read every row. The anon key ships in the client bundle by design.

After you create any table, your migration must include:

```sql alter table public.your_table enable row level security;

create policy "Users see their own rows" on public.your_table for select using (auth.uid() = user_id);

create policy "Users can insert their own rows" on public.your_table for insert with check (auth.uid() = user_id); ```

The most common Supabase data-leak bug is forgetting one of these on one table. AI tools often skip the policy on tables they think "internal." There are no internal tables in a hosted Postgres reachable via the anon key.

### Mistake 5 — server-side route handlers that bypass RLS

You have /api/orders/[id]/route.ts that uses the service-role key (legitimately, server-side). The handler reads the order WITHOUT comparing the requesting user to the order's owner.

// WRONG — service-role bypasses RLS, no manual ownership check
export async function GET(req, { params }) {
  const supabase = createAdminClient();  // service-role
  const { data } = await supabase.from("orders").select("*").eq("id", params.id).single();
  return Response.json(data);
}

Any logged-in user can read any order by changing the URL. The fix: use the user-scoped client (anon key + JWT from request), so RLS enforces ownership; OR if you legitimately need service-role (admin endpoints, webhooks), explicitly compare the authenticated user against the resource owner before responding.

```ts // RIGHT — anon-key client + RLS import { createClient } from "@/lib/supabase/server";

export async function GET(req, { params }) { const supabase = await createClient(); // anon + JWT const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response(null, { status: 401 });

const { data } = await supabase.from("orders").select("*").eq("id", params.id).single(); if (!data) return new Response(null, { status: 404 });

return Response.json(data); } ```

The .eq("id", params.id) looks like the only filter, but RLS adds an implicit AND user_id = auth.uid() based on the policy. The single result returned by the user's anon-key client is ALREADY filtered by ownership.

How to test that your auth actually works

After the setup, run these tests. They're embarrassing if you skip them.

### Test 1 — unauthenticated access to a protected route

curl -i https://your-app.com/app/dashboard

Should return 302/redirect to login. If it returns the dashboard HTML, your middleware matcher is wrong.

### Test 2 — cross-user access via API

Sign in as user A. Get user B's resource ID (you may need a second test account). Open dev tools, copy your auth cookie. Then:

curl -H "Cookie: <your-cookie>" https://your-app.com/api/orders/<userB-order-id>

Should return 404. If it returns user B's order, you have BOLA.

### Test 3 — service-role key in client bundle

curl -s https://your-app.com/_next/static/chunks/main-*.js | grep -E "service_role|SUPABASE_SERVICE"

Should return nothing. If it returns matches, you have service-role in your client bundle.

### Test 4 — RLS enabled on every table

In the Supabase SQL editor, as the postgres role:

select schemaname, tablename, rowsecurity from pg_tables
where schemaname = 'public' and rowsecurity = false;

Should return zero rows. Each row is a table missing RLS.

Stop checking these manually

You can run the 4 tests above before every deploy. You will not, in practice, after the first month. Solo founders ship faster than they audit.

The fix is automated review. Securie runs the broken-auth specialist (Day-1 production-validated) and the Supabase RLS specialist (Day-1 production-validated) on every PR. Findings ship only when the sandbox successfully reproduces the cross-user access — no false positives, no triage burden, just "fix this before merge."

Free during early access, 2-minute install, works with whatever AI coding tool generated the auth code.

Related

Related posts