HomeBlogNext.js + Supabase Authentication: The Complete 2026 Guide
Next.jsSupabaseAuthenticationOAuth

Next.js + Supabase Authentication: The Complete 2026 Guide

Learn how to implement email, OAuth, magic links, and session management with Next.js and Supabase. Covers middleware-based route protection, token refresh, and common pitfalls.

SaaSInMinutes
9 min read

Why Authentication Is the Hardest Part of Starting a SaaS

Every SaaS needs authentication. And every developer who's built it from scratch knows it's deceptively complex. The login form is the easy part. The hard part is everything else:

  • Secure token storage and refresh
  • OAuth callback handling across providers
  • Email verification flows
  • Password reset with expiring tokens
  • Session management across tabs
  • Middleware-based route protection
  • CSRF protection
  • Rate limiting on auth endpoints

This guide covers how to implement all of it with Next.js and Supabase — and where the common pitfalls are. If you'd rather skip the setup entirely, see our 30-minute Next.js + Supabase launch guide.

Setting Up Supabase Auth

Create Your Supabase Project

Head to supabase.com and create a new project. You'll need:

  • Project URL: https://your-project.supabase.co
  • Anon Key: The public key for client-side operations
  • Service Role Key: The secret key for server-side operations (never expose this)

Install the Dependencies

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

The @supabase/ssr package is critical — it handles cookie-based auth that works with Next.js server components and middleware.

Client Setup for the App Router

You need two separate Supabase clients: one for the browser, one for the server.

Browser Client

The browser client runs in client components and uses cookies to manage the session:

import { createBrowserClient } from '@supabase/ssr';

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

Server Client

The server client reads cookies from the request to reconstruct the session:

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createServerSupabaseClient() {
  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)
          );
        },
      },
    }
  );
}

Implementing Email + Password Auth

Sign Up

const { data, error } = await supabase.auth.signUp({
  email: 'user@example.com',
  password: 'securepassword123',
  options: {
    emailRedirectTo: `${window.location.origin}/auth/callback`,
  },
});

The emailRedirectTo is crucial — it's where Supabase sends the user after they click the verification link.

Sign In

const { data, error } = await supabase.auth.signInWithPassword({
  email: 'user@example.com',
  password: 'securepassword123',
});

Common Pitfall: Email Confirmation

By default, Supabase requires email confirmation. If you're testing locally and emails aren't arriving, check the Supabase dashboard under Authentication > Settings. You can disable email confirmation for development, but always keep it enabled in production.

Adding OAuth Providers

Google OAuth

  1. Create credentials in the Google Cloud Console
  2. Add the client ID and secret in Supabase Dashboard > Authentication > Providers > Google
  3. Set the redirect URL to https://your-project.supabase.co/auth/v1/callback

Then in your code:

const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: `${window.location.origin}/auth/callback`,
  },
});

GitHub OAuth

Same pattern — create an OAuth app in GitHub Settings, add credentials to Supabase, then:

const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'github',
  options: {
    redirectTo: `${window.location.origin}/auth/callback`,
  },
});

The Auth Callback Route

Every OAuth flow needs a callback route to exchange the code for a session:

// app/auth/callback/route.ts
import { createServerSupabaseClient } from '@/lib/supabase-server';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get('code');
  const next = searchParams.get('next') ?? '/dashboard';

  if (code) {
    const supabase = await createServerSupabaseClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  return NextResponse.redirect(`${origin}/auth/error`);
}

Magic links let users sign in without a password — they click a link in their email and they're in:

const { data, error } = await supabase.auth.signInWithOtp({
  email: 'user@example.com',
  options: {
    emailRedirectTo: `${window.location.origin}/auth/callback`,
  },
});

This is increasingly popular for SaaS products because it removes password friction entirely.

Middleware-Based Route Protection

This is where most tutorials fall short. You need middleware that:

  1. Refreshes the session token on every request
  2. Redirects unauthenticated users away from protected routes
  3. Redirects authenticated users away from auth pages
// middleware.ts
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) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  // Protect dashboard routes
  if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

export const config = {
  matcher: ['/dashboard/:path*', '/account/:path*'],
};

Why getUser() Instead of getSession()?

getSession() reads from the local JWT without verifying it with Supabase. getUser() makes a network call to verify the token is still valid. In middleware, always use getUser() — it catches revoked sessions and expired tokens.

Stop wiring this up by hand. SaaSInMinutes ships the full auth stack — email + password, Google and GitHub OAuth, magic links, middleware protection, session refresh, and rate limiting — already wired and tested. $49 one-time. Get instant access →

Session Management Across Tabs

Supabase handles this automatically via its onAuthStateChange listener. Set it up once in your root layout:

'use client';

import { useEffect } from 'react';
import { createClient } from '@/lib/supabase-browser';
import { useRouter } from 'next/navigation';

export function AuthListener() {
  const router = useRouter();
  const supabase = createClient();

  useEffect(() => {
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (event) => {
        if (event === 'SIGNED_OUT') {
          router.push('/login');
        }
        router.refresh();
      }
    );

    return () => subscription.unsubscribe();
  }, [router, supabase]);

  return null;
}

Security Considerations

Rate Limiting

Supabase applies rate limiting on auth endpoints by default, but you should add your own application-level rate limiting for login attempts. A simple approach using an in-memory store works for single-server deployments:

const loginAttempts = new Map<string, { count: number; resetAt: number }>();

function isRateLimited(ip: string): boolean {
  const now = Date.now();
  const record = loginAttempts.get(ip);

  if (!record || now > record.resetAt) {
    loginAttempts.set(ip, { count: 1, resetAt: now + 900000 }); // 15 min window
    return false;
  }

  record.count++;
  return record.count > 5; // Max 5 attempts per 15 minutes
}

Row Level Security

Your Supabase database should use Row Level Security (RLS) policies to ensure users can only access their own data. This is your last line of defense if a token is compromised:

CREATE POLICY "Users can only read own data"
  ON profiles FOR SELECT
  USING (auth.uid() = id);

For multi-tenant SaaS — where users belong to organizations and need access to shared org data — RLS gets significantly more nuanced. See our deep dive on Supabase RLS for multi-tenant SaaS for organization-based policies, role hierarchies, and the performance tradeoffs.

Skip the Setup, Start Building

Reading through this guide gives you a sense of the surface area — and we haven't even covered password reset flows, email templates, account deletion, or the dozen small things that break in production. Wiring it all up cleanly takes most teams 15–25 hours.

SaaSInMinutes ships it done. For $49 one-time, you get:

  • Email + password auth with verification and password reset
  • Google and GitHub OAuth, pre-configured
  • Magic links
  • Middleware-based route protection with token refresh
  • Session management and onAuthStateChange wiring
  • Rate limiting on auth endpoints
  • RLS policies and tested security defaults
  • Plus payments, monitoring, email, and production AWS deployment

Spend your time on the features that make your SaaS unique, not on login forms.

Get instant access — $49 one-time →

Written by SaaSInMinutes