How to Fix Cursor-Generated Next.js Auth Loops (And 4 Other Common Bugs)
If you built your Next.js app with Cursor and your users are stuck in infinite auth redirects, you're not alone. Here are the 5 most common bugs and how to fix them.
If you built your Next.js app with Cursor and your users are getting stuck in infinite login redirects, welcome to the club.
We've fixed this exact bug in 8 different codebases this year alone. It's the single most common issue we see in AI-generated Next.js apps — and it's always the same root cause.
Here are the 5 bugs we fix most often in Cursor-generated Next.js applications, with the actual code to resolve each one.
Bug 1: The infinite auth redirect loop
What it looks like
Your user logs in successfully. The auth provider confirms the session. Then the browser starts bouncing: /dashboard -> /login -> /dashboard -> /login -> repeat. Sometimes it stops after 5-6 redirects with a browser error. Sometimes it just spins forever.
Why Cursor generates this
Cursor typically generates two layers of auth protection that conflict with each other:
- Middleware that checks for a session token and redirects unauthenticated users to
/login - Server Components that independently check auth state and redirect to
/login
The problem: after login, the middleware runs before the server component. If the middleware checks cookies but the auth provider stores the session in a different format than expected, the middleware sees "no session" and redirects — even though the user just authenticated.
The fix
The issue is almost always in middleware.ts. Here's what Cursor typically generates:
// middleware.ts — Cursor's version (broken)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
if (!session) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
The fix depends on your auth provider, but the pattern is the same — you need to:
- Match the exact cookie name your auth provider uses
- Exclude auth callback routes from the matcher
- Let the auth provider's own redirect logic handle the post-login flow
// middleware.ts — fixed version
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Use the exact cookie name from your auth provider
// Better Auth: "better-auth.session_token"
// Supabase: "sb-<project-ref>-auth-token"
// NextAuth: "next-auth.session-token"
const session = request.cookies.get("better-auth.session_token");
// Don't redirect if we're already on an auth route
const isAuthRoute = request.nextUrl.pathname.startsWith("/login") ||
request.nextUrl.pathname.startsWith("/api/auth");
if (!session && !isAuthRoute) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
// Don't let authenticated users hit the login page
if (session && isAuthRoute && !request.nextUrl.pathname.startsWith("/api/auth")) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
// Exclude API auth routes, static files, and public assets
matcher: [
"/((?!api/auth|_next/static|_next/image|favicon.ico).*)",
],
};
Key insight: The /api/auth callback routes must be excluded from middleware protection. Cursor almost never does this. The auth provider needs those routes accessible to complete the OAuth flow or session creation.
How to detect it
Open your browser's Network tab. If you see a chain of 307/302 redirects alternating between two URLs, this is your bug.
Bug 2: Supabase session not persisting across server/client boundary
What it looks like
Users log in on the client side. The session works in client components. But server components, API routes, and server actions all show the user as unauthenticated. Data fetching in server components returns empty results or 401 errors.
Why Cursor generates this
Cursor creates Supabase client instances correctly for either the browser or the server — but not both in a way that shares the session. It typically generates:
// lib/supabase.ts — Cursor's version (broken in server context)
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
This creates a single client that works in the browser but has no access to cookies in server-side code. The session exists in the browser's local storage, but Next.js server components can't read local storage.
The fix
You need separate Supabase clients for browser, server component, and middleware contexts:
// lib/supabase/client.ts — browser client
"use client";
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// lib/supabase/server.ts — server client
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) {
try {
for (const { name, value, options } of cookiesToSet) {
cookieStore.set(name, value, options);
}
} catch {
// Ignore: setAll can fail in Server Components
// (they're read-only). Middleware handles refresh.
}
},
},
}
);
}
// middleware.ts — refresh session on every request
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
for (const { name, value, options } of cookiesToSet) {
request.cookies.set(name, value);
supabaseResponse.cookies.set(name, value, options);
}
},
},
}
);
// Refresh the session — this is the critical line
await supabase.auth.getUser();
return supabaseResponse;
}
Key insight: The middleware must call supabase.auth.getUser() on every request to refresh the session token and set the updated cookies. Without this, expired tokens never get refreshed and users silently lose their session.
How to detect it
Add a temporary log to a server component: console.log(await supabase.auth.getUser()). If it returns null while the user is visibly logged in on the client, this is your bug.
Bug 3: API routes not validating auth tokens
What it looks like
Your API works correctly when called from your frontend. But anyone with the endpoint URL can hit it directly — no auth check, no token validation. Your data is effectively public to anyone who inspects network requests.
Why Cursor generates this
Cursor generates API route handlers that assume the request is always coming from an authenticated frontend. It trusts the client.
// app/api/invoices/route.ts — Cursor's version (no auth)
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET() {
const invoices = await db.query.invoices.findMany();
return NextResponse.json(invoices);
}
The fix
Every API route needs to verify the session before doing anything:
// app/api/invoices/route.ts — fixed version
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import { invoices } from "@/lib/db/schema";
export async function GET(request: NextRequest) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Scope data to the authenticated user's organization
const userInvoices = await db.query.invoices.findMany({
where: eq(invoices.orgId, session.user.orgId),
});
return NextResponse.json(userInvoices);
}
Key insight: Notice the query filter. It's not enough to check that the user is authenticated — you also need to scope the data to what they're allowed to see. Cursor almost never adds tenant-level data scoping.
How to detect it
Open a new incognito window. Hit your API route directly: curl https://yourapp.com/api/invoices. If you get data back instead of a 401, every one of your API routes is probably unprotected.
Bug 4: Race condition in Stripe webhook + database update
What it looks like
A customer pays. Your Stripe webhook fires. Sometimes the subscription updates correctly. Sometimes it doesn't. You check Stripe — the payment went through. You check your database — the user is still on the free plan. It works 80% of the time, fails 20% of the time with zero error logs.
Why Cursor generates this
Cursor generates webhook handlers that process events synchronously without handling two critical edge cases:
- Duplicate events. Stripe sends the same webhook multiple times if your endpoint is slow to respond.
- Out-of-order events.
checkout.session.completedmight arrive afterinvoice.paiddue to network timing.
// Cursor's version — no idempotency, no ordering
export async function POST(req: NextRequest) {
const body = await req.json();
if (body.type === "checkout.session.completed") {
const session = body.data.object;
await db.update(users)
.set({ plan: "pro", stripeCustomerId: session.customer })
.where(eq(users.email, session.customer_email));
}
return NextResponse.json({ received: true });
}
The fix
// app/api/webhooks/stripe/route.ts — fixed version
import { NextResponse, type NextRequest } from "next/server";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { users, webhookEvents } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";
const webhookSecret = z.string().min(1).parse(
process.env.STRIPE_WEBHOOK_SECRET
);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
if (!sig) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
// Step 1: Verify the webhook signature
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Step 2: Idempotency check — skip if already processed
const existing = await db.query.webhookEvents.findFirst({
where: eq(webhookEvents.stripeEventId, event.id),
});
if (existing) {
return NextResponse.json({ received: true });
}
// Step 3: Record the event before processing
await db.insert(webhookEvents).values({
stripeEventId: event.id,
eventType: event.type,
processedAt: new Date(),
});
// Step 4: Process the event
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
await db.update(users)
.set({
plan: "pro",
stripeCustomerId: session.customer as string,
subscriptionStatus: "active",
})
.where(eq(users.email, session.customer_email as string));
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
await db.update(users)
.set({
plan: "free",
subscriptionStatus: "canceled",
})
.where(eq(users.stripeCustomerId, subscription.customer as string));
break;
}
}
// Step 5: Respond quickly — Stripe retries if you take >30s
return NextResponse.json({ received: true });
}
Key insight: The webhookEvents table is your idempotency layer. Without it, duplicate Stripe events cause duplicate database writes. The signature verification prevents anyone from faking webhook calls. Both are missing in every Cursor-generated Stripe integration we've audited.
How to detect it
Go to your Stripe Dashboard > Developers > Webhooks. Look at the event delivery attempts. If you see multiple successful deliveries for the same event, or deliveries that took more than 10 seconds, you likely have this bug.
Bug 5: Environment variables leaking to client bundle
What it looks like
Your app works fine. No visible errors. But someone views your page source or inspects the JavaScript bundle and finds your database connection string, Stripe secret key, or API credentials embedded in the client-side code.
Why Cursor generates this
Cursor doesn't consistently distinguish between server-side and client-side environment variables. In Next.js, only variables prefixed with NEXT_PUBLIC_ should be accessible on the client. Cursor frequently references server-only variables in client components or shared utility files.
// lib/config.ts — Cursor's version (leaks secrets)
export const config = {
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
databaseUrl: process.env.DATABASE_URL,
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
openaiKey: process.env.OPENAI_API_KEY,
};
If this file gets imported by any client component — even indirectly through a chain of imports — the values get bundled into the client JavaScript. Next.js won't throw an error. It'll just silently include whatever the build process can resolve.
The fix
Separate your config into server-only and client-safe modules using the server-only package:
// lib/config.server.ts — server only
import "server-only";
import { z } from "zod";
const serverEnvSchema = z.object({
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
DATABASE_URL: z.string().url(),
OPENAI_API_KEY: z.string().min(1),
});
export const serverEnv = serverEnvSchema.parse(process.env);
// lib/config.client.ts — safe for client
import { z } from "zod";
const clientEnvSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
});
export const clientEnv = clientEnvSchema.parse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});
The import "server-only" directive causes a build error if a client component tries to import this file — even indirectly. This is your safety net.
Key insight: The Zod validation serves double duty. It validates that required variables exist at startup (no more "undefined" crashes in production), and it creates a typed object so you get autocomplete and type safety. If a variable is missing, the app fails to start with a clear error message instead of silently breaking at runtime.
How to detect it
Run your production build and search the output:
# Build the app
pnpm build
# Search for leaked secrets in the client bundle
grep -r "sk_live\|sk_test\|postgresql://\|mongodb+srv" .next/static
If grep returns anything, you have leaked secrets in your client bundle. Rotate those credentials immediately.
The pattern behind all 5 bugs
These bugs share a common root: AI code generators optimize for the happy path. They generate code that works when everything goes right. Auth flows that work when the session is fresh. Database queries that work when the user is honest. Webhook handlers that work when events arrive once, in order, from Stripe.
Production software needs to handle everything that goes wrong. That's the gap between a demo and a deployed product.
If you've built with Cursor, Bolt, Lovable, or any AI tool — and you're now in production with real users — the question isn't whether you have these bugs. It's how many of them you have.
Ready to get started?
Have more than 2 of these? A Rescue Sprint fixes all critical issues and gives you a production-ready backend.