Advanced Authentication Patterns with Next.js and Supabase
Developer Guide

Advanced Authentication Patterns with Next.js and Supabase

Advanced Next.js + Supabase auth patterns: OAuth, magic links, passwordless, custom JWT, multi-tenant auth, and enterprise SSO integration.

2026-03-19
50 min read
Advanced Authentication Patterns with Next.js and Supabase

Photo by FlyD on Unsplash

Authentication is the gateway to your application. While basic email/password authentication works for simple apps, production applications need sophisticated authentication patterns that balance security, user experience, and business requirements. This guide covers the patterns that matter in a Next.js + Supabase stack — OAuth, magic links, custom JWT claims, multi-tenant auth, and enterprise SSO — using the current @supabase/ssr package (the deprecated @supabase/auth-helpers-nextjs patterns you'll find in older tutorials are not what's below).

Three ground rules from Supabase's own server-side auth docs that every pattern here respects:

  1. Never trust getSession() in server code. Supabase's warning is verbatim: "Never trust supabase.auth.getSession() inside server code... It isn't guaranteed to revalidate the Auth token." Use supabase.auth.getUser() (a network call that revalidates) or getClaims() (local JWT signature validation) in Server Components, Route Handlers, and middleware.
  2. Server clients handle cookies with getAll/setAll. The per-cookie get/set/remove callbacks floating around older guides are the previous API shape.
  3. Authorization data belongs in app_metadata, never user_metadata. Supabase documents that raw_user_meta_data "can be updated by the authenticated user" — a user can promote themselves if you gate on it — while raw_app_meta_data "cannot be updated by the user, so it's a good place to store authorization data."

Authentication Fundamentals#

Before diving into advanced patterns, understand these core concepts:

Authentication vs Authorization:

  • Authentication: Verifies who you are (login)
  • Authorization: Determines what you can do (permissions)

Stateful vs Stateless:

  • Stateful: Server stores session data (traditional approach)
  • Stateless: Client stores token, server verifies (JWT approach)

Token Types:

  • Access Token: Short-lived (1 hour), used for API requests
  • Refresh Token: Long-lived (7 days), used to get new access tokens
  • ID Token: Contains user information, used for authentication

1. OAuth 2.0 Implementation#

OAuth delegates authentication to trusted providers, reducing your security burden.

Configuring OAuth Providers#

In Supabase Dashboard:

  1. Go to Authentication → Providers
  2. Enable desired providers (Google, GitHub, Discord, etc.)
  3. Add OAuth credentials from provider
  4. Configure redirect URLs

Implementing OAuth Sign-In#

typescript
// app/auth/oauth/page.tsx
'use client';
 
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
 
export default function OAuthPage() {
  const router = useRouter();
  const supabase = createClient();
 
  async function signInWithGoogle() {
    const { data, error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`
      }
    });
 
    if (error) {
      console.error('OAuth error:', error);
    }
  }
 
  async function signInWithGitHub() {
    const { data, error } = await supabase.auth.signInWithOAuth({
      provider: 'github',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`
      }
    });
 
    if (error) {
      console.error('OAuth error:', error);
    }
  }
 
  return (
    <div className="space-y-4">
      <button onClick={signInWithGoogle} className="btn btn-google">
        Sign in with Google
      </button>
      <button onClick={signInWithGitHub} className="btn btn-github">
        Sign in with GitHub
      </button>
    </div>
  );
}

OAuth Callback Handler#

typescript
// app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');
  const error = searchParams.get('error');
 
  if (error) {
    return NextResponse.redirect(
      new URL(`/auth/error?error=${error}`, request.url)
    );
  }
 
  if (code) {
    const cookieStore = await cookies(); // Next.js 15: cookies() is async
    const supabase = createServerClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
      {
        // Current @supabase/ssr cookie contract: getAll/setAll.
        // The older per-cookie get/set/remove callbacks are the previous API.
        cookies: {
          getAll() {
            return cookieStore.getAll();
          },
          setAll(cookiesToSet) {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          },
        },
      }
    );
 
    const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);
 
    if (!exchangeError) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }
 
  return NextResponse.redirect(new URL('/auth/error', request.url));
}

Account Linking#

Allow users to link multiple OAuth providers:

typescript
// Link additional OAuth provider to existing account
async function linkOAuthProvider(provider: string) {
  const supabase = createClient();
  
  const { data, error } = await supabase.auth.linkIdentity({
    provider,
    options: {
      redirectTo: `${window.location.origin}/auth/callback`
    }
  });
 
  if (error) {
    console.error('Linking error:', error);
  }
}
 
// Unlink OAuth provider
async function unlinkOAuthProvider(provider: string) {
  const supabase = createClient();
  
  const { data, error } = await supabase.auth.unlinkIdentity({
    identity_id: 'provider_id'
  });
 
  if (error) {
    console.error('Unlinking error:', error);
  }
}

2. Passwordless Authentication#

Passwordless auth improves security and user experience by eliminating passwords.

typescript
// app/auth/magic-link/page.tsx
'use client';
 
import { createClient } from '@/lib/supabase/client';
import { useState } from 'react';
 
export default function MagicLinkPage() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [message, setMessage] = useState('');
  const supabase = createClient();
 
  async function handleMagicLink(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
 
    const { error } = await supabase.auth.signInWithOtp({
      email,
      options: {
        emailRedirectTo: `${window.location.origin}/auth/callback`
      }
    });
 
    if (error) {
      setMessage(`Error: ${error.message}`);
    } else {
      setMessage('Check your email for the magic link!');
      setEmail('');
    }
 
    setLoading(false);
  }
 
  return (
    <form onSubmit={handleMagicLink} className="space-y-4">
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Sending...' : 'Send Magic Link'}
      </button>
      {message && <p>{message}</p>}
    </form>
  );
}

One-Time Password (OTP)#

typescript
// Send OTP via SMS or email
async function sendOTP(phone: string) {
  const supabase = createClient();
  
  const { data, error } = await supabase.auth.signInWithOtp({
    phone,
    options: {
      shouldCreateUser: true
    }
  });
 
  if (error) {
    console.error('OTP error:', error);
  }
}
 
// Verify OTP
async function verifyOTP(phone: string, token: string) {
  const supabase = createClient();
  
  const { data, error } = await supabase.auth.verifyOtp({
    phone,
    token,
    type: 'sms'
  });
 
  if (error) {
    console.error('Verification error:', error);
  }
 
  return data;
}

3. Custom JWT Claims with the Custom Access Token Hook#

Old tutorials (and an earlier version of this guide) show minting a second, parallel JWT with jsonwebtoken and your own JWT_SECRET. Don't. You end up maintaining two token lifecycles, two expiry clocks, and RLS can't see your custom token at all.

Supabase's documented mechanism is the Custom Access Token Hook: a Postgres function that "runs before a token is issued and allows you to add additional claims" to the Supabase access token itself. Your role and tenant claims then ride in the same JWT that RLS policies, auth.jwt(), and the client library already use.

The hook receives an event object with user_id, the current claims, and the authentication_method ("password", "oauth", "otp", "sso/saml", "token_refresh", and so on — you can vary claims by how the user signed in). It must return an updated claims object, and eleven claims are mandatory and cannot be removed: iss, aud, exp, iat, sub, role, aal, session_id, email, phone, is_anonymous. Supabase validates the output and rejects the token if any are missing.

sql
-- Runs as part of token issuance. Add org + role claims from your own tables.
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
as $$
declare
  claims jsonb;
  user_role text;
  user_org uuid;
begin
  select role, organization_id into user_role, user_org
  from public.organization_members
  where user_id = (event->>'user_id')::uuid
  limit 1;
 
  claims := event->'claims';
 
  -- Put custom authorization data under app_metadata: users cannot edit it.
  claims := jsonb_set(claims, '{app_metadata,user_role}', to_jsonb(coalesce(user_role, 'viewer')));
  if user_org is not null then
    claims := jsonb_set(claims, '{app_metadata,organization_id}', to_jsonb(user_org));
  end if;
 
  return jsonb_set(event, '{claims}', claims);
end;
$$;

Enable it under Authentication → Hooks in the Supabase Dashboard, then read the claims anywhere the JWT goes:

sql
-- In an RLS policy: no extra query, the claim is already in the token
create policy "Admins can update projects" on projects
  for update to authenticated
  using (
    (auth.jwt() -> 'app_metadata' ->> 'organization_id')::uuid = organization_id
    and auth.jwt() -> 'app_metadata' ->> 'user_role' = 'admin'
  );

Two caveats straight from Supabase's docs:

  • Staleness is real. A JWT "is not always fresh" — a role change lands only when the token is refreshed (default access-token lifetime is short, but a just-demoted admin keeps admin claims until then). For instant-revocation semantics, check the database, not the token.
  • Keep the payload small. The hook is a common source of JWT bloat; every claim you add travels on every request in the auth cookie.

4. Multi-Tenant Authentication#

Implement authentication for multi-tenant SaaS applications:

typescript
// lib/multi-tenant-auth.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
 
export async function getUserWithOrganization() {
  const cookieStore = await cookies();
  const supabase = 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)
          );
        },
      },
    }
  );
 
  const { data: { user } } = await supabase.auth.getUser();
 
  if (!user) {
    return null;
  }
 
  // Get user's organizations
  const { data: organizations } = await supabase
    .from('organization_members')
    .select(`
      organization_id,
      role,
      organizations(id, name, slug)
    `)
    .eq('user_id', user.id);
 
  return {
    user,
    organizations: organizations || []
  };
}
 
// Verify user belongs to organization
export async function verifyOrganizationAccess(
  userId: string,
  organizationId: string
): Promise<boolean> {
  const cookieStore = await cookies();
  const supabase = 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)
          );
        },
      },
    }
  );
 
  const { data } = await supabase
    .from('organization_members')
    .select('id')
    .eq('user_id', userId)
    .eq('organization_id', organizationId)
    .single();
 
  return !!data;
}

Multi-Tenant Middleware#

Middleware must resolve the user itself — with getUser(), which revalidates the token, never getSession() (see the ground rules at the top). It reads cookies from the incoming request and writes any refreshed tokens onto both the request and the response, exactly as Supabase's middleware recipe prescribes:

typescript
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } from 'next/server';
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Extract organization slug from URL
  const match = pathname.match(/^\/org\/([^/]+)/);
  if (!match) {
    return NextResponse.next();
  }
  const organizationSlug = match[1];
 
  let response = 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)
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );
 
  // getUser() revalidates the token with Supabase Auth on every call.
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
 
  // Verify membership: slug -> organization -> membership row
  const { data: membership } = await supabase
    .from('organization_members')
    .select('role, organizations!inner(slug)')
    .eq('user_id', user.id)
    .eq('organizations.slug', organizationSlug)
    .maybeSingle();
 
  if (!membership) {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }
 
  return response;
}
 
export const config = {
  matcher: ['/org/:path*']
};

5. Enterprise SSO (SAML 2.0, Built Into Supabase)#

You do not need Auth0, WorkOS, or a hand-rolled organization_sso table for this. Supabase Auth ships native SAML 2.0 support on Pro plans and above — your project's Auth server is the SAML service provider. The moving parts:

Registering an identity provider happens through the Supabase CLI, not client code. Each enterprise customer hands you their IdP metadata (Okta, Entra ID, Google Workspace...):

bash
supabase sso add \
  --project-ref your-project-ref \
  --type saml \
  --metadata-url 'https://idp.customer.com/app/metadata.xml' \
  --domains customer.com

You give the customer's IT team two URLs from your project in return — the Entity ID (https://<project>.supabase.co/auth/v1/sso/saml/metadata) and the ACS URL (https://<project>.supabase.co/auth/v1/sso/saml/acs) — and their assertions must use a NameID format of emailAddress or persistent.

Sign-in is then one client call. Supabase resolves the provider by email domain and redirects to the customer's IdP:

typescript
// SP-initiated flow: user types their work email, you route by domain
async function signInWithEnterpriseSSO(email: string) {
  const supabase = createClient();
  const domain = email.split('@')[1];
 
  const { data, error } = await supabase.auth.signInWithSSO({
    domain, // or { providerId: '<uuid>' } if you store the mapping yourself
  });
 
  if (error) throw error;
  if (data?.url) {
    window.location.href = data.url; // redirect to the customer's IdP
  }
}

IdP-initiated flows (users launching your app from their Okta/Entra dashboard) are supported too, with no extra code on your side.

Three documented restrictions to design around before you sell the enterprise tier:

  • Attribute mapping is explicit. Supabase auto-detects the email address, but every other assertion attribute (name, department...) must be mapped via a JSON attribute-mapping file passed to the CLI.
  • SSO users can't use identity linking. Accounts verified through an SSO provider aren't eligible for linking with other providers.
  • Email is no longer a unique identifier across SSO providers — key your own tables on the user's UUID, never the email.

6. Step-Up Authentication#

Require additional verification for sensitive operations. One honest caveat about the snippet below: it tracks the step-up timestamp in localStorage, which is a UX gate, not a security boundary — anything in localStorage can be edited in DevTools. Treat it as the client-side half only; the server-side half is enforcing recency on the backend (with MFA, Supabase encodes the assurance level in the JWT's aal claim — one of the mandatory claims listed in section 3 — which RLS policies and server code can check).

typescript
// lib/step-up-auth.ts
const STEP_UP_TIMEOUT = 15 * 60 * 1000; // 15 minutes
 
export async function requireStepUpAuth(userId: string): Promise<boolean> {
  const lastStepUp = localStorage.getItem(`step-up-${userId}`);
  const now = Date.now();
 
  if (!lastStepUp || now - parseInt(lastStepUp) > STEP_UP_TIMEOUT) {
    // Require re-authentication
    return false;
  }
 
  return true;
}
 
export function recordStepUpAuth(userId: string) {
  localStorage.setItem(`step-up-${userId}`, Date.now().toString());
}
 
// Usage in sensitive operation
async function changePassword(userId: string, newPassword: string) {
  const hasStepUp = await requireStepUpAuth(userId);
 
  if (!hasStepUp) {
    // Redirect to re-authentication
    throw new Error('Step-up authentication required');
  }
 
  // Change password
  const supabase = createClient();
  const { error } = await supabase.auth.updateUser({
    password: newPassword
  });
 
  if (!error) {
    recordStepUpAuth(userId);
  }
 
  return error;
}

7. Authentication Error Handling#

typescript
// lib/auth-errors.ts
export function getAuthErrorMessage(error: any): string {
  const errorCode = error?.code || error?.message;
 
  const messages: Record<string, string> = {
    'invalid_credentials': 'Invalid email or password',
    'user_not_found': 'User not found',
    'email_not_confirmed': 'Please confirm your email',
    'weak_password': 'Password is too weak',
    'user_already_exists': 'User already exists',
    'over_request_rate_limit': 'Too many requests. Please try again later',
    'session_not_found': 'Session expired. Please log in again',
    'invalid_grant': 'Invalid credentials',
    'invalid_request': 'Invalid request'
  };
 
  return messages[errorCode] || 'An authentication error occurred';
}
 
// Usage
async function handleLogin(email: string, password: string) {
  const supabase = createClient();
 
  const { error } = await supabase.auth.signInWithPassword({
    email,
    password
  });
 
  if (error) {
    const message = getAuthErrorMessage(error);
    console.error(message);
    return { error: message };
  }
 
  return { success: true };
}

8. Authentication Best Practices Checklist#

  • ✅ Use HTTPS in production
  • ✅ Store tokens in httpOnly cookies
  • ✅ Implement token refresh logic
  • ✅ Use strong password requirements
  • ✅ Implement rate limiting on auth endpoints
  • ✅ Enable MFA for sensitive accounts
  • ✅ Log authentication events
  • ✅ Implement step-up authentication for sensitive operations
  • ✅ Use OAuth for consumer apps
  • ✅ Implement SAML for enterprise customers
  • ✅ Handle authentication errors gracefully
  • ✅ Implement account linking
  • ✅ Regular security audits

Conclusion#

Advanced authentication patterns enable you to build secure, scalable applications that meet diverse user and business requirements. Start with basic OAuth for consumer apps, add passwordless authentication for better UX, and implement enterprise features like SAML for B2B customers.

Remember: authentication is the foundation of security. Invest time in getting it right, and your users will thank you with their trust.

Frequently Asked Questions

|

Have more questions? Contact us

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.