IAMUVIN

Full-Stack Engineering

Authentication in Next.js with Supabase: Production Patterns

Uvin Vindula·August 5, 2024·12 min read

Last updated: April 14, 2026

Share

TL;DR

Authentication is the first thing I build on every project and the last thing I want to debug at 2am. After implementing auth on uvin.lk, EuroParts Lanka, and a dozen client projects, I've landed on a set of patterns with Supabase Auth + Next.js middleware that handle the edge cases most tutorials ignore. This article covers the full production auth stack: email/password signup, OAuth with Google and GitHub, middleware-based session refresh, protected routes on both server and client, role-based access control with Supabase RLS, a reusable auth context provider, password reset flows, and the specific bugs I've hit in production. If you're building anything that requires users to log in, these patterns will save you from the session expiry nightmares and OAuth race conditions that only show up under real traffic.


Auth Architecture

Before writing a single line of auth code, I establish the architecture. Every decision here cascades through the entire app, so getting it right upfront matters more than any individual feature.

Here's the architecture I use on every project:

Browser
  |
  v
Next.js Middleware (session refresh on every request)
  |
  v
Route Handler / Server Component (validate session server-side)
  |
  v
Supabase Auth (JWT + refresh tokens, managed by Supabase)
  |
  v
PostgreSQL RLS (row-level security enforces access at the database layer)

The key insight: auth validation happens at multiple layers, not just one. Middleware refreshes the session. Server components verify it. RLS enforces it at the data layer. If any single layer fails, the others catch it.

I create three separate Supabase clients for different contexts. This is not optional — using the wrong client in the wrong context is the source of most auth bugs I see in Next.js projects.

typescript
// 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) {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options)
          );
        },
      },
    }
  );
}
typescript
// 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!
  );
}
typescript
// lib/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextRequest, NextResponse } from "next/server";

export async function updateSession(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();

  return { supabase, user, response: supabaseResponse };
}

Three clients, three contexts, zero confusion. The server client reads and writes cookies through the cookies() API. The browser client uses the browser's cookie jar. The middleware client reads from the request and writes to the response. Mixing these up causes silent auth failures that are incredibly hard to trace.


Email/Password Flow

Email/password is the baseline. Every project gets it, even if OAuth is the primary flow. Some users don't trust third-party login, and having a fallback matters.

typescript
// app/(auth)/signup/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { z } from "zod";

const signupSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[0-9]/, "Password must contain a number")
    .regex(/[^A-Za-z0-9]/, "Password must contain a special character"),
  fullName: z.string().min(2, "Name must be at least 2 characters"),
});

export async function signup(formData: FormData) {
  const rawData = {
    email: formData.get("email") as string,
    password: formData.get("password") as string,
    fullName: formData.get("fullName") as string,
  };

  const result = signupSchema.safeParse(rawData);

  if (!result.success) {
    return {
      error: result.error.issues[0].message,
    };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.signUp({
    email: result.data.email,
    password: result.data.password,
    options: {
      data: {
        full_name: result.data.fullName,
      },
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
    },
  });

  if (error) {
    if (error.message.includes("already registered")) {
      return { error: "An account with this email already exists" };
    }
    return { error: "Something went wrong. Please try again." };
  }

  redirect("/auth/check-email");
}
typescript
// app/(auth)/login/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function login(formData: FormData) {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  if (!email || !password) {
    return { error: "Email and password are required" };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  if (error) {
    if (error.message.includes("Invalid login credentials")) {
      return { error: "Invalid email or password" };
    }
    if (error.message.includes("Email not confirmed")) {
      return { error: "Please verify your email before logging in" };
    }
    return { error: "Something went wrong. Please try again." };
  }

  redirect("/dashboard");
}

Two things I always do that tutorials skip. First, I never expose Supabase error messages directly to users. Those messages leak implementation details. I map them to user-friendly strings. Second, I validate passwords with Zod on the server action, not just on the client form. Client-side validation is a UX feature. Server-side validation is a security feature. They serve different purposes.


OAuth -- Google/GitHub

OAuth is where auth gets interesting. The happy path works in every tutorial. The edge cases destroy you in production.

typescript
// app/(auth)/login/oauth-actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function signInWithGoogle() {
  const supabase = await createClient();

  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: "google",
    options: {
      redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
      queryParams: {
        access_type: "offline",
        prompt: "consent",
      },
    },
  });

  if (error) {
    return { error: "Failed to initiate Google login" };
  }

  redirect(data.url);
}

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`,
      scopes: "read:user user:email",
    },
  });

  if (error) {
    return { error: "Failed to initiate GitHub login" };
  }

  redirect(data.url);
}

The callback handler is the critical piece. This is where OAuth callback races happen, and where most production auth bugs live.

typescript
// app/auth/callback/route.ts
import { createClient } from "@/lib/supabase/server";
import { NextRequest, NextResponse } from "next/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";
  const error = searchParams.get("error");
  const errorDescription = searchParams.get("error_description");

  // Handle OAuth provider errors
  if (error) {
    console.error(`OAuth error: ${error} - ${errorDescription}`);
    return NextResponse.redirect(
      `${origin}/login?error=${encodeURIComponent(
        "Authentication failed. Please try again."
      )}`
    );
  }

  if (!code) {
    return NextResponse.redirect(
      `${origin}/login?error=${encodeURIComponent("No authorization code received")}`
    );
  }

  const supabase = await createClient();
  const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);

  if (exchangeError) {
    console.error("Code exchange failed:", exchangeError.message);
    return NextResponse.redirect(
      `${origin}/login?error=${encodeURIComponent(
        "Login failed. Please try again."
      )}`
    );
  }

  // Validate the redirect target to prevent open redirect attacks
  const redirectUrl = next.startsWith("/") ? `${origin}${next}` : origin;

  return NextResponse.redirect(redirectUrl);
}

The prompt: "consent" on Google OAuth is deliberate. Without it, Google caches the consent screen and sometimes skips it entirely on re-login. That causes a bug where users who revoked access from their Google account settings still get logged in with stale tokens. Forcing consent on every login prevents this.

I also validate the next parameter to prevent open redirect attacks. Without that check, an attacker could craft a callback URL like /auth/callback?next=https://evil.com and redirect users to a phishing page after a legitimate login.


Session Management with Middleware

Middleware is the heartbeat of session management. Every request passes through it, which makes it the perfect place to refresh expired sessions before they cause problems.

typescript
// middleware.ts
import { updateSession } from "@/lib/supabase/middleware";
import { NextRequest, NextResponse } from "next/server";

const publicRoutes = [
  "/",
  "/login",
  "/signup",
  "/auth/callback",
  "/auth/check-email",
  "/auth/reset-password",
  "/blog",
  "/about",
  "/services",
  "/contact",
];

const authRoutes = ["/login", "/signup"];

function isPublicRoute(pathname: string): boolean {
  return publicRoutes.some(
    (route) => pathname === route || pathname.startsWith(`${route}/`)
  );
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Skip middleware for static assets and API routes that handle their own auth
  if (
    pathname.startsWith("/_next") ||
    pathname.startsWith("/api/webhooks") ||
    pathname.includes(".")
  ) {
    return NextResponse.next();
  }

  const { user, response } = await updateSession(request);

  // Redirect authenticated users away from auth pages
  if (user && authRoutes.some((route) => pathname.startsWith(route))) {
    const url = request.nextUrl.clone();
    url.pathname = "/dashboard";
    return NextResponse.redirect(url);
  }

  // Redirect unauthenticated users to login for protected routes
  if (!user && !isPublicRoute(pathname)) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("next", pathname);
    return NextResponse.redirect(url);
  }

  return response;
}

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

The updateSession call inside middleware does something crucial: it calls supabase.auth.getUser(), which refreshes the JWT if it's expired. The refreshed token gets written back to the response cookies via the setAll callback. Without this, users get randomly logged out when their JWT expires (default: 1 hour), even though they have a valid refresh token.

I learned this the hard way on EuroParts Lanka. Users were reporting intermittent logouts, but only after being idle for about an hour. The JWT was expiring, and without middleware-based refresh, the next server component call would see an expired token and treat the user as unauthenticated.

One detail: I store the next path when redirecting to login. After successful login, the user lands back where they were trying to go, not on some generic dashboard. Small UX detail, big impact on user satisfaction.


Protected Routes -- Server and Client

Auth checks happen in two places: server components for initial page loads and client components for interactive state. Both need different patterns.

Server-Side Protection

typescript
// lib/auth/get-session.ts
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

export async function getAuthenticatedUser() {
  const supabase = await createClient();

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

  if (error || !user) {
    redirect("/login");
  }

  return user;
}

export async function getOptionalUser() {
  const supabase = await createClient();

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

  return user;
}
typescript
// app/dashboard/page.tsx
import { getAuthenticatedUser } from "@/lib/auth/get-session";

export default async function DashboardPage() {
  const user = await getAuthenticatedUser();

  return (
    <div>
      <h1>Welcome, {user.user_metadata.full_name}</h1>
      {/* Dashboard content */}
    </div>
  );
}

Client-Side Protection

typescript
// components/auth/require-auth.tsx
"use client";

import { useAuth } from "@/providers/auth-provider";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

interface RequireAuthProps {
  children: React.ReactNode;
  fallback?: React.ReactNode;
}

export function RequireAuth({ children, fallback }: RequireAuthProps) {
  const { user, isLoading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!isLoading && !user) {
      router.push("/login");
    }
  }, [user, isLoading, router]);

  if (isLoading) {
    return fallback ?? <AuthSkeleton />;
  }

  if (!user) {
    return null;
  }

  return <>{children}</>;
}

function AuthSkeleton() {
  return (
    <div className="flex items-center justify-center min-h-[200px]">
      <div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
    </div>
  );
}

I always use getUser(), never getSession(), for auth checks on the server. This is a critical distinction. getSession() reads the JWT from cookies and decodes it locally — it does not validate the token with Supabase. A user who's been banned or whose session was revoked will still pass a getSession() check until the JWT expires. getUser() makes a round-trip to Supabase and validates the token. For protected routes, that network call is worth the latency.


Role-Based Access Control

Most apps outgrow simple authenticated/unauthenticated checks within weeks. You need roles. I store roles in the database and use Supabase RLS policies to enforce them at the data layer.

sql
-- Migration: create user roles
CREATE TYPE user_role AS ENUM ('user', 'editor', 'admin', 'super_admin');

ALTER TABLE public.profiles
  ADD COLUMN role user_role NOT NULL DEFAULT 'user';

-- RLS policy: users can only read their own profile
CREATE POLICY "Users can view own profile"
  ON public.profiles
  FOR SELECT
  USING (auth.uid() = id);

-- RLS policy: admins can read all profiles
CREATE POLICY "Admins can view all profiles"
  ON public.profiles
  FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM public.profiles
      WHERE id = auth.uid() AND role IN ('admin', 'super_admin')
    )
  );

-- RLS policy: only super_admins can update roles
CREATE POLICY "Super admins can update roles"
  ON public.profiles
  FOR UPDATE
  USING (
    EXISTS (
      SELECT 1 FROM public.profiles
      WHERE id = auth.uid() AND role = 'super_admin'
    )
  );
typescript
// lib/auth/rbac.ts
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";

type Role = "user" | "editor" | "admin" | "super_admin";

const ROLE_HIERARCHY: Record<Role, number> = {
  user: 0,
  editor: 1,
  admin: 2,
  super_admin: 3,
};

export async function requireRole(minimumRole: Role) {
  const supabase = await createClient();

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

  if (error || !user) {
    redirect("/login");
  }

  const { data: profile } = await supabase
    .from("profiles")
    .select("role")
    .eq("id", user.id)
    .single();

  if (!profile) {
    redirect("/login");
  }

  const userLevel = ROLE_HIERARCHY[profile.role as Role] ?? 0;
  const requiredLevel = ROLE_HIERARCHY[minimumRole];

  if (userLevel < requiredLevel) {
    redirect("/unauthorized");
  }

  return { user, role: profile.role as Role };
}
typescript
// app/admin/page.tsx
import { requireRole } from "@/lib/auth/rbac";

export default async function AdminPage() {
  const { user, role } = await requireRole("admin");

  return (
    <div>
      <h1>Admin Dashboard</h1>
      <p>Logged in as {user.email} with role: {role}</p>
    </div>
  );
}

The role hierarchy approach means I don't need to check for every specific role — an admin automatically has access to anything an editor can see. I use a numeric hierarchy rather than array-based role checks because it scales better and the comparison logic is trivial.

RLS policies in Supabase are the last line of defense. Even if my application code has a bug that skips a role check, the database itself won't return data the user shouldn't see. Defense in depth. This has saved me from at least two bugs where a client-side routing error briefly showed an admin page to a regular user — the page rendered but the data queries returned empty because RLS blocked them.


Auth Context Provider

Client components need access to the current user without prop drilling. I use a context provider that listens for auth state changes and keeps the client in sync with the server.

typescript
// providers/auth-provider.tsx
"use client";

import { createClient } from "@/lib/supabase/client";
import type { User } from "@supabase/supabase-js";
import {
  createContext,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";

interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType>({
  user: null,
  isLoading: true,
  signOut: async () => {},
});

export function AuthProvider({
  children,
  initialUser,
}: {
  children: React.ReactNode;
  initialUser: User | null;
}) {
  const [user, setUser] = useState<User | null>(initialUser);
  const [isLoading, setIsLoading] = useState(!initialUser);
  const supabase = useMemo(() => createClient(), []);

  useEffect(() => {
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      setUser(session?.user ?? null);
      setIsLoading(false);

      if (event === "SIGNED_OUT") {
        // Clear any cached data
        window.location.href = "/login";
      }

      if (event === "TOKEN_REFRESHED") {
        // Token was refreshed successfully — no action needed
        // but useful for debugging session issues
      }
    });

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

  const signOut = async () => {
    await supabase.auth.signOut();
  };

  const value = useMemo(
    () => ({ user, isLoading, signOut }),
    [user, isLoading]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}
typescript
// app/layout.tsx
import { AuthProvider } from "@/providers/auth-provider";
import { getOptionalUser } from "@/lib/auth/get-session";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getOptionalUser();

  return (
    <html lang="en">
      <body>
        <AuthProvider initialUser={user}>{children}</AuthProvider>
      </body>
    </html>
  );
}

The initialUser prop is important. The root layout fetches the user on the server and passes it down. This means the client provider starts with the correct user state immediately — no flash of unauthenticated content, no loading spinner on first render. The onAuthStateChange listener then keeps it in sync for subsequent changes like token refresh or sign out.

I use window.location.href instead of router.push on sign out. This forces a full page reload, which clears any cached server component data. Without this, users sometimes see stale authenticated content for a split second after logging out because Next.js serves the cached RSC payload.


Password Reset Flow

Password reset is two separate flows: requesting the reset and actually resetting the password. Both need careful handling.

typescript
// app/(auth)/forgot-password/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";

export async function requestPasswordReset(formData: FormData) {
  const email = formData.get("email") as string;

  if (!email) {
    return { error: "Email is required" };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=/auth/reset-password`,
  });

  // Always return success to prevent email enumeration
  if (error) {
    console.error("Password reset error:", error.message);
  }

  return {
    success: true,
    message: "If an account exists with that email, you will receive a reset link.",
  };
}
typescript
// app/(auth)/reset-password/actions.ts
"use server";

import { createClient } from "@/lib/supabase/server";
import { z } from "zod";

const resetSchema = z.object({
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Must contain an uppercase letter")
    .regex(/[0-9]/, "Must contain a number")
    .regex(/[^A-Za-z0-9]/, "Must contain a special character"),
});

export async function resetPassword(formData: FormData) {
  const password = formData.get("password") as string;
  const confirmPassword = formData.get("confirmPassword") as string;

  if (password !== confirmPassword) {
    return { error: "Passwords do not match" };
  }

  const result = resetSchema.safeParse({ password });

  if (!result.success) {
    return { error: result.error.issues[0].message };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.updateUser({
    password: result.data.password,
  });

  if (error) {
    return { error: "Failed to update password. The reset link may have expired." };
  }

  return { success: true };
}

The security detail most tutorials miss: I always return success on password reset requests, even if the email doesn't exist. Returning "email not found" tells attackers which emails are registered. This is called email enumeration, and it's a real attack vector. Log the error server-side for debugging, but never expose it to the client.

The flow works through the same /auth/callback route as OAuth, with a next parameter that redirects to the reset password form. Supabase includes a code in the redirect that gets exchanged for a session, which means when the user lands on the reset form, they're already authenticated with a temporary session. updateUser then changes the password on that session.


Common Auth Bugs I've Hit

These are real bugs from real projects. Every one of them cost me at least an hour of debugging the first time.

The Middleware Cookie Bug

Symptom: Users get logged out randomly, but only on certain routes.

Cause: The middleware wasn't returning the response with updated cookies. If updateSession refreshes the JWT, the new token is written to cookies on the response object. If you return a different NextResponse (like a redirect), those cookies are lost.

typescript
// WRONG - loses refreshed cookies
export async function middleware(request: NextRequest) {
  const { user } = await updateSession(request);
  if (!user) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next(); // Fresh response, no cookies
}

// CORRECT - preserves refreshed cookies
export async function middleware(request: NextRequest) {
  const { user, response } = await updateSession(request);
  if (!user) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    // Create redirect from the response that has updated cookies
    const redirectResponse = NextResponse.redirect(url);
    response.cookies.getAll().forEach((cookie) => {
      redirectResponse.cookies.set(cookie.name, cookie.value);
    });
    return redirectResponse;
  }
  return response; // Response with refreshed cookies
}

The OAuth Race Condition

Symptom: OAuth login works 95% of the time. Occasionally, users land on the callback page with a "code already used" error.

Cause: The browser makes two requests to the callback URL — one from the redirect and one from a prefetch or the React strict mode double-render. The first request exchanges the code successfully. The second request tries to exchange the same code and fails.

Fix: Handle the "code already used" error gracefully by checking if the user is already authenticated:

typescript
// In auth/callback/route.ts
const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);

if (exchangeError) {
  // Check if user is already authenticated (race condition case)
  const { data: { user } } = await supabase.auth.getUser();
  if (user) {
    // Code was already exchanged — redirect normally
    return NextResponse.redirect(`${origin}${next}`);
  }
  // Genuine error
  return NextResponse.redirect(`${origin}/login?error=auth_failed`);
}

The getSession vs getUser Confusion

Symptom: Banned users can still access protected pages until their JWT expires.

Cause: Using getSession() instead of getUser() for auth checks. getSession() only reads the local JWT without validating it server-side.

Fix: Always use getUser() for authorization decisions. Use getSession() only when you need the access token to call external APIs and speed matters more than real-time validation.

The Expired Refresh Token

Symptom: Users who haven't visited the site in weeks get a white page instead of a login redirect.

Cause: Both the JWT and refresh token expired. The middleware's getUser() call throws an error that isn't caught, causing the middleware to crash silently.

Fix: Wrap the middleware auth check in a try/catch and redirect to login on any error:

typescript
try {
  const { user, response } = await updateSession(request);
  // ... rest of middleware
} catch {
  // Session completely expired — redirect to login
  const url = request.nextUrl.clone();
  url.pathname = "/login";
  return NextResponse.redirect(url);
}

My Auth Checklist

Before shipping auth on any project, I run through this list. Every item has cost me or a client real time when missed.

Security:

  • [ ] Server-side validation on all auth forms with Zod
  • [ ] getUser() for auth checks, never getSession() alone
  • [ ] No email enumeration on password reset or signup
  • [ ] Open redirect prevention on callback routes
  • [ ] CSRF protection via Supabase's built-in PKCE flow
  • [ ] Rate limiting on login and signup endpoints
  • [ ] Secure cookie flags (httpOnly, secure, sameSite: lax)
  • [ ] Session revocation works immediately (test by banning a user)

UX:

  • [ ] Redirect to intended page after login (preserve next param)
  • [ ] No flash of unauthenticated content on protected pages
  • [ ] Loading states during auth checks (skeleton, not blank page)
  • [ ] Clear error messages that don't leak implementation details
  • [ ] Redirect authenticated users away from login/signup pages
  • [ ] Password strength indicator on signup and reset forms

Infrastructure:

  • [ ] Middleware refreshes sessions on every request
  • [ ] Cookie handling preserves tokens across redirects
  • [ ] OAuth callback handles race conditions gracefully
  • [ ] RLS policies enforce access at the database layer
  • [ ] Auth state syncs between server and client (AuthProvider)
  • [ ] Sign out clears all cached state (full page reload)
  • [ ] Email templates customized (not default Supabase templates)
  • [ ] Webhook for auth events (user.created, user.deleted) for syncing profiles

Key Takeaways

  1. Three Supabase clients, three contexts. Server, browser, and middleware each need their own client with appropriate cookie handling. Mixing them causes silent auth failures.
  1. Middleware is the session heartbeat. Refresh tokens on every request through middleware. Without this, users get logged out when their JWT expires, even with valid refresh tokens.
  1. `getUser()` over `getSession()`. Always. getSession() reads local JWTs without server validation. For auth decisions, make the round trip.
  1. Defense in depth. Middleware checks, server component checks, and RLS policies. Three layers. If one fails, the others catch it.
  1. Handle the edge cases. OAuth race conditions, expired refresh tokens, middleware cookie loss — these bugs only surface under real traffic. Build for them upfront.
  1. Never enumerate emails. Password reset and signup should return the same response whether the email exists or not. Attackers probe these endpoints.
  1. Roles belong in the database. Not in JWTs, not in localStorage, not in auth metadata. In a table with RLS policies enforcing access at the query level.
  1. Auth is a feature, not a checkbox. It touches every part of the app. Invest the time to build it right, and you'll stop debugging session issues at 2am.

Authentication isn't the most exciting part of building an app, but it's the part that earns user trust. Get it wrong and nothing else matters. These patterns have survived production traffic on every project I've shipped, from uvin.lk to EuroParts Lanka, and they'll work for yours too.

If you're building something that needs production-grade auth and want to move faster, check out my services — I've implemented this stack more times than I can count.


*Written by Uvin Vindula — full-stack and Web3 engineer building from Sri Lanka and the UK. I write about the patterns I actually use in production, not the ones that only work in tutorials. Follow my work at @IAMUVIN or explore more at uvin.lk.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.