Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15
The complete guide to Supabase authentication and session management in Next.js 15. Middleware patterns, cookie handling, refresh tokens, MFA, redirect URLs, and the silent failures that ruin production auth.
Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15#
Authentication is the feature most teams underestimate. It looks simple — log in, log out, protect routes. Then production hits. Tokens expire at 3am. Cookies vanish on Safari mobile. Login works locally but redirects to localhost:3000 on a Vercel preview. MFA prompts loop forever. Every edge case costs hours.
This guide is the single place to get Supabase auth right in Next.js 15. It covers session refresh, cookie handling across server and client, redirect URLs, MFA, token rotation, and every silent failure mode that has ever made me question my life choices at 2am.
Start with the Mental Model#
Supabase auth is a JWT-based system with refresh tokens. The pieces:
- Access token (JWT): short-lived (one hour by default), carries the user's identity
- Refresh token: long-lived, used to mint new access tokens without re-prompting the user
- Cookies: store both tokens so the browser can send them with every request
In Next.js 15 App Router, these tokens live in cookies set by the @supabase/ssr package. Server components read them via cookies() from next/headers. Client components read them via the browser's cookie jar directly. Middleware bridges both.
If you do not have middleware refreshing sessions on every request, access tokens silently expire after one hour and users get logged out mid-task. This is the single biggest Supabase auth bug in the wild.
Use @supabase/ssr, Not the Auth Helpers#
The older @supabase/auth-helpers-nextjs is deprecated as of 2024. In 2026, the only supported path is @supabase/ssr.
npm install @supabase/ssr @supabase/supabase-js
npm uninstall @supabase/auth-helpers-nextjs
If your codebase mixes the two, strip auth-helpers out completely before adding anything new. They do not compose.
The Three Clients You Need#
In App Router you interact with Supabase from three contexts: the browser, server components/actions, and middleware. Each needs a different client factory.
Browser Client#
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
Use this in client components, event handlers, and useEffect.
Server Client#
// src/lib/supabase/server.ts
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 {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// setAll in a server component — ignore; middleware will refresh.
}
},
},
}
);
}
Use this in server components, server actions, and route handlers.
Middleware Client#
// src/lib/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function updateSession(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)
);
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
return { response, user };
}
The middleware client is the critical one — it is what refreshes expired access tokens on every request.
Wire Up the Middleware#
Create middleware.ts at the project root:
import { NextResponse, type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';
const PUBLIC_PATHS = ['/login', '/signup', '/auth/callback', '/'];
const AUTH_PATHS = ['/login', '/signup'];
export async function middleware(request: NextRequest) {
const { response, user } = await updateSession(request);
const { pathname } = request.nextUrl;
// Redirect logged-in users away from login and signup
if (user && AUTH_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Redirect logged-out users away from protected routes
const isPublic = PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p));
if (!user && !isPublic) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('next', pathname);
return NextResponse.redirect(loginUrl);
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
Three things this does:
- Calls
supabase.auth.getUser()on every request, which transparently refreshes the access token if needed - Sets updated cookies on the response so future requests use the fresh token
- Handles common auth redirects so your pages do not have to
The getUser vs getSession Footgun#
This is the single most misunderstood part of Supabase auth.
const { data: { session } } = await supabase.auth.getSession();
// session comes from the cookie — NOT verified
getSession() reads what is in the cookie and returns it. It does not verify the JWT signature. On the client, this is fine — the browser trusts its own cookies. On the server, this is a vulnerability. Anyone can craft a cookie with a fake JWT and getSession() will happily return it.
const { data: { user } } = await supabase.auth.getUser();
// user comes from a verified JWT — safe on the server
getUser() actually contacts Supabase (or verifies the JWT locally with the project's public key) and returns a user only if the token is valid.
Rule: in server code, always use getUser(). Only use getSession() in client code, and only when you need a synchronous check.
The Auth Callback Route#
OAuth and magic-link flows redirect back to your app via /auth/callback. This route must exchange the code for a session and set cookies correctly:
// src/app/auth/callback/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/dashboard';
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
const forwardedHost = request.headers.get('x-forwarded-host');
const isLocal = process.env.NODE_ENV === 'development';
if (isLocal) {
return NextResponse.redirect(`${origin}${next}`);
} else if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`);
} else {
return NextResponse.redirect(`${origin}${next}`);
}
}
}
return NextResponse.redirect(`${origin}/auth/error`);
}
The x-forwarded-host check is what saves you on Vercel preview deployments. Without it, the redirect goes to the internal Vercel hostname instead of the user-facing domain.
Redirect URL Allowlist (the #1 Production Break)#
Every Supabase project has a strict allowlist of redirect URLs. Any emailRedirectTo or OAuth redirectTo not on the list is rejected.
Go to Authentication → URL Configuration and add:
https://yourdomain.com/auth/callback
https://www.yourdomain.com/auth/callback
https://*-yourorg.vercel.app/auth/callback
http://localhost:3000/auth/callback
The wildcard pattern https://*-yourorg.vercel.app/auth/callback covers every preview deployment. Without it, every preview URL breaks auth and your PR reviewers assume your feature is broken.
Also set Site URL to your canonical production domain. This is used as a fallback when redirectTo is not supplied.
Sign In with Email/Password#
// src/app/login/actions.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export async function signIn(formData: FormData) {
const supabase = await createClient();
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
return { error: error.message };
}
redirect('/dashboard');
}
Returning { error } lets the form show field-level messages without throwing. redirect() on success triggers a server-side redirect that preserves the new session cookies.
Sign In with Magic Link#
export async function signInWithMagicLink(formData: FormData) {
const supabase = await createClient();
const email = formData.get('email') as string;
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
shouldCreateUser: false, // prevents accidental signup
},
});
if (error) return { error: error.message };
return { success: true };
}
Set shouldCreateUser: false unless you explicitly want magic-link signups. Otherwise a typo in the email creates a new account silently.
Sign In with OAuth#
export async function signInWithGitHub() {
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
});
if (error) return { error: error.message };
if (data.url) redirect(data.url);
}
OAuth flows bounce the user to the provider and back. Your /auth/callback route handles the code exchange.
Sign Out#
export async function signOut() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect('/login');
}
For logging out across every device:
await supabase.auth.signOut({ scope: 'global' });
global revokes all refresh tokens for the user — every device, every tab. Useful after a password change or a suspected compromise.
Multi-Factor Authentication (MFA)#
Enterprise buyers will demand MFA. Supabase supports TOTP (authenticator apps) natively.
Enrolling a User#
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator App',
});
// Display data.totp.qr_code (SVG) and data.totp.secret
Show the QR code to the user, ask them to scan it, and then verify the code they enter.
Challenging and Verifying#
const { data: challengeData } = await supabase.auth.mfa.challenge({
factorId: data.id,
});
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId: data.id,
challengeId: challengeData.id,
code: userEnteredCode,
});
Gating Routes by Authenticator Assurance Level#
After MFA verification, the user's session has aal: 'aal2'. Gate sensitive routes:
const { data: { user } } = await supabase.auth.getUser();
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (aalData?.currentLevel !== 'aal2') {
redirect('/auth/mfa-challenge');
}
Apply this in middleware for every route under /settings/security or /admin.
Session Persistence and Cookie Edge Cases#
The Safari Intelligent Tracking Prevention Problem#
Safari limits third-party cookies and some first-party storage under ITP. If your Supabase project is at *.supabase.co and your app is at yourdomain.com, Safari may treat the Supabase cookies as third-party.
Fix: use a custom domain for your Supabase auth. Available on Pro plan and above, it lets auth cookies be first-party to your main domain.
Cookie Domain Misconfiguration#
If your app spans subdomains (app.yourdomain.com and marketing.yourdomain.com), configure cookie domain explicitly:
// in middleware updateSession
response.cookies.set(name, value, {
...options,
domain: '.yourdomain.com',
});
Without the leading dot, subdomains cannot share the cookie.
SameSite and Secure Attributes#
@supabase/ssr sets these correctly by default. If you override them, ensure production cookies are secure: true and sameSite: 'lax' (or 'none' if you have cross-site auth flows).
Rate Limiting Authentication Endpoints#
Supabase provides some rate limiting by default. For serious protection, add your own at the middleware level.
// using Upstash Redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '60 s'),
});
// in login action
const ip = headers().get('x-forwarded-for') ?? 'anonymous';
const { success } = await ratelimit.limit(`login:${ip}`);
if (!success) return { error: 'Too many attempts. Try again in a minute.' };
Five login attempts per minute per IP is a reasonable baseline. Tighten for signup flows to prevent email bombing.
Handling Common Auth Errors#
"Invalid Login Credentials"#
Do not tell the user whether the email exists. Return a single generic message. "Invalid login credentials" is fine. Leaking which emails are registered helps credential-stuffing attackers.
"Email Not Confirmed"#
Let the user resend the confirmation:
await supabase.auth.resend({
type: 'signup',
email,
options: { emailRedirectTo: `${siteUrl}/auth/callback` },
});
"Refresh Token Not Found"#
This fires when cookies get cleared, usually because the browser was closed for longer than the refresh token lifetime (30 days default). Redirect to login.
"For security purposes, you can only request this once every 60 seconds"#
Supabase rate-limits OTP requests. Respect it — surface the message, let the user wait.
Debugging Auth Issues#
When a user cannot log in, check in this order:
- Network tab: does the request hit Supabase at all? If not, wrong URL in env vars.
- Response body: what error does Supabase return?
- Cookies after login: are
sb-*-auth-tokencookies set? If not,setAllis not being called — check middleware wiring. - Cookies on subsequent requests: do they include the auth cookie? If not, domain or sameSite misconfiguration.
- Redirect URL: did the user land somewhere unexpected? Check allowlist and
x-forwarded-hosthandling.
A good 80% of auth bugs resolve at step 3 or 4.
Protecting Server Components#
// src/app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/login');
// ... render dashboard
}
Middleware already redirects unauthenticated users. The getUser() call here is defense in depth — if middleware is somehow skipped, the page still refuses to render.
Protecting API Route Handlers#
// src/app/api/posts/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// ... process request
}
Return 401 for API calls. Do not redirect — the caller might be JSON-only.
What About Service-Role Access?#
Sometimes you need to bypass RLS (cron jobs, admin endpoints, webhooks). Create a separate client:
// src/lib/supabase/admin.ts
import { createClient } from '@supabase/supabase-js';
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
}
Rules:
- Never import this in client code
- Never expose it through an unauthenticated endpoint
- Always audit where it is used:
grep -r "createAdminClient" src/
Password Reset Flow#
export async function requestPasswordReset(formData: FormData) {
const supabase = await createClient();
const email = formData.get('email') as string;
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/reset-password`,
});
if (error) return { error: error.message };
return { success: true };
}
The user clicks the link in the email, lands on /auth/reset-password, which is the callback. The session is temporarily elevated, and they can call updateUser to set a new password:
export async function updatePassword(formData: FormData) {
const supabase = await createClient();
const password = formData.get('password') as string;
const { error } = await supabase.auth.updateUser({ password });
if (error) return { error: error.message };
redirect('/dashboard');
}
Checklist Before You Call Auth "Done"#
Before you consider your auth flow production-ready, verify every one of these:
- [ ] Middleware calls
supabase.auth.getUser()on every protected route - [ ] Server code uses
getUser(), nevergetSession() - [ ] Redirect URL allowlist includes production, preview, and localhost
- [ ]
x-forwarded-hosthandling in the callback route - [ ] Email confirmation is required for new signups
- [ ] Rate limiting on login, signup, and password reset
- [ ] MFA is available for enterprise accounts (even if optional)
- [ ] Global sign-out clears all refresh tokens
- [ ] Password reset flow works end-to-end
- [ ] Service-role client is not imported in client code
- [ ] Error messages do not leak account existence
Every item on that list maps to a real incident that happened to someone.
Related Reading#
- Debugging Supabase RLS Issues for the other half of the auth equation
- Handling Supabase Auth Errors with Middleware for a field-tested error-handling pattern
- Supabase Authentication & Authorization Guide for the policy-level details
- Next.js 15 Middleware Patterns to expand middleware beyond auth
Conclusion#
Supabase auth is not hard. It is just unforgiving about details. The pattern is fixed: middleware refreshes sessions, server code verifies with getUser, callbacks handle OAuth and magic links, cookies carry the state. Once that scaffolding is solid, MFA, rate limiting, and enterprise requirements slot on top without drama.
Get the foundation right once, and you never have to think about it again — which is exactly what good infrastructure does.
Frequently Asked Questions
Related Guides
Next.js 15 Middleware: Complete Guide to Auth, Rate Limiting, A/B Testing, and Edge Logic
The definitive Next.js 15 middleware guide. Auth, rate limiting, A/B testing, geolocation, bot protection, security headers, and request logging — with complete code for the Vercel Edge runtime.
Next.js App Router + Supabase SSR Session Management Deep Dive
Deep dive into Supabase SSR session management in Next.js App Router. Learn how cookies, middleware, and Server Components interact to keep users authenticated.
The Complete Next.js + Supabase Production Launch Checklist (47 Items)
The definitive pre-launch audit for Next.js 15 + Supabase applications. 47 concrete checks across security, RLS, performance, observability, auth, and deployment — every item caused a production incident somewhere.