Building Secure Server-Side Authentication with Next.js and Supabase

By Eric Trotchie and ClaudeOctober 17, 2025

Next.jsSupabaseAuthenticationServer ActionsSecurity

Authentication is the backbone of any modern web application. In this post, I'll walk you through how I built a secure, server-side authentication system using Next.js 15, Supabase, and Drizzle ORM—complete with role-based routing and comprehensive error handling.

But more importantly, I'll be honest about what works, what doesn't, and what's missing. Authentication is never truly "done"—it's a journey of continuous improvement. I'll share both the architecture I implemented and the security enhancements I'd add next.

The Architecture

The implementation follows a layered architecture that separates concerns and maintains security:

Core Components

Server Actions handle all authentication logic server-side, preventing sensitive operations from running in the browser. Supabase Auth manages user credentials and sessions, while Drizzle ORM maintains user profiles in our database. Zod validation ensures data integrity before it reaches our services. Middleware acts as the gatekeeper, protecting routes and enforcing role-based access at the edge.

The Flow

When a user signs up, the request flows through form submission to server action validation. After Supabase creates the auth user, we create a corresponding database profile. If the profile creation fails, we rollback the auth user to maintain consistency.

For every subsequent request, middleware intercepts it before reaching your pages. It validates the session, checks user roles, and enforces route protection rules—all before your React components even mount.

Implementation Deep Dive

The Auth Service Layer

I created a dedicated AuthService class that encapsulates all authentication logic:

export class AuthService {
  private supabase: SupabaseClient;
  private db: DrizzleClient;

  constructor(supabase: SupabaseClient, db: DrizzleClient) {
    this.supabase = supabase;
    this.db = db;
  }

  public async signUpUser(
    input: z.infer
  ): Promise<ServiceResponse> {
    // Validate input
    const validation = signUpFormSchema.safeParse(input);
    if (!validation.success) {
      return {
        success: false,
        error: { message: formatError(validation.error) },
      };
    }

    // Create auth user in Supabase
    const { data: authData, error: authError } =
      await this.supabase.auth.signUp({
        email,
        password,
      });

    // Create database profile
    // With rollback on failure
  }
}

This approach provides a clean separation between authentication logic and the framework, making the code testable and reusable.

Server Actions for Security

All authentication operations run as server actions, ensuring credentials never touch the client:

export async function signUpUser(
  prevState: AuthUserForm | undefined,
  formData: FormData
) {
  const validation = signUpFormSchema.safeParse({
    email: formData.get("email") as string,
    password: formData.get("password") as string,
    confirmPassword: formData.get("confirm-password") as string,
  });

  if (!validation.success) {
    return {
      success: false,
      data: { email: formData.get("email") as string },
      error: { message: formatError(validation.error) },
    };
  }

  const supabase = await createClient();
  const authService = new AuthService(supabase, db);
  const result = await authService.signUpUser(validatedFields);

  // Role-based routing
  const userRole = result.data?.user_metadata.role || "user";
  
  if (userRole === "user") {
    revalidatePath("/customer/dashboard");
    return redirect("/customer/dashboard");
  }

  if (userRole === "admin") {
    revalidatePath("/admin/dashboard");
    return redirect("/admin/dashboard");
  }
}

Progressive Enhancement with useActionState

The forms use React's useActionState hook for progressive enhancement:

const CredentialsSignUpForm = () => {
  const initialState: AuthUserForm = {
    success: false,
    data: { email: "" },
    error: { message: "" },
  };

  const [data, action] = useActionState(signUpUser, initialState);
  const { pending } = useFormStatus();

  return (
    
      
      
      
        {pending ? "Authenticating..." : "Sign up"}
      

      {data && !data.success && (
        {data?.error.message}
      )}
    
  );
};

This provides instant feedback while maintaining server-side security.

The Dual Database Approach

One unique aspect of this implementation is maintaining user data in both Supabase Auth and a separate database via Drizzle ORM.

Why Two Databases?

Supabase Auth handles authentication credentials, sessions, and security-critical operations. Drizzle Database stores application-specific user data, relationships, and business logic.

This separation allows for flexible data modeling while leveraging Supabase's battle-tested auth infrastructure.

Transaction Safety with Rollback

When creating a new user, if the database profile creation fails, we rollback the Supabase auth user:

try {
  const newUser: NewUser = {
    supabaseUserId: authUser.id,
    email: authUser.email!,
  };
  await this.db.insert(users).values(newUser);
  return { success: true, data: authUser };
} catch (dbError) {
  const supabaseAdmin = createAdminClient();
  
  await supabaseAdmin.auth.admin.deleteUser(authUser.id);
  
  return {
    success: false,
    error: { message: formatError(dbError) },
  };
}

This prevents orphaned auth users and maintains data consistency.

Role-Based Access Control

The system implements role-based routing with three user types:

Regular users access customer dashboards, admins manage application data, and super admins have elevated privileges. Roles are stored in Supabase user metadata and checked during sign-in to route users appropriately.

Middleware: The Gatekeeper

While server actions handle authentication, middleware protects your routes and enforces role-based access control. This is where the real security magic happens.

Route Protection Strategy

I implemented a Next.js middleware that checks every request before it reaches your pages:

export async function updateSession(request: NextRequest) {
  // Create Supabase client with cookie handling
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          // Update both request and response cookies
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value),
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options),
          );
        },
      },
    },
  );

  // Get user claims
  const { data } = await supabase.auth.getClaims();
  const user = data?.claims;
  const userRole = user?.user_metadata?.role;

  // Determine route types
  const pathname = request.nextUrl.pathname;
  const isProtectedCustomerRoute = pathname.startsWith('/customer');
  const isProtectedAdminRoute = pathname.startsWith('/admin');
  const isAdmin = userRole === 'super_admin' || userRole === 'admin';

  // Protection logic
  // 1. Redirect unauthenticated users to sign-in
  if (!user && (isProtectedCustomerRoute || isProtectedAdminRoute)) {
    const url = request.nextUrl.clone();
    url.pathname = "/sign-in";
    return NextResponse.redirect(url);
  }

  // 2. Redirect admins away from customer routes
  if (user && isProtectedCustomerRoute && isAdmin) {
    const url = request.nextUrl.clone();
    url.pathname = "/admin/dashboard";
    return NextResponse.redirect(url);
  }

  // 3. Redirect non-admins away from admin routes
  if (user && isProtectedAdminRoute && !isAdmin) {
    const url = request.nextUrl.clone();
    url.pathname = "/customer/dashboard";
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

Key Middleware Concepts

Cookie Management is critical—the middleware must properly propagate Supabase session cookies. Failing to do this will log users out randomly. getClaims() is mandatory—calling supabase.auth.getClaims() refreshes the session and prevents unexpected logouts. Skip this, and users will mysteriously lose their sessions. Path-based protection uses simple startsWith() checks for route patterns, which is more maintainable than regex patterns.

The Four Protection Rules

The middleware enforces four critical rules:

  1. No entry without authentication - Unauthenticated users trying to access /customer/* or /admin/* get redirected to sign-in
  2. Admins skip customer routes - If an admin somehow hits a customer route, redirect them to the admin dashboard
  3. Users stay out of admin areas - Regular users attempting to access admin routes get sent back to their customer dashboard
  4. Public routes stay open - Any route not matching protected patterns allows access

Why This Matters

Middleware runs on every request before your pages load, making it impossible for unauthorized users to even render protected pages. This is edge-level security—protection happens before your React components even mount.

Compare this to client-side protection:

// ❌ Bad: Client-side only
function Dashboard() {
  const { user } = useAuth();
  if (!user) return ;
  return ;
}

// ✅ Good: Middleware + server components
// User never reaches the component if unauthorized

The Pros

Security First

All sensitive operations occur server-side. Credentials never touch the browser, and sessions are managed securely by Supabase. Middleware provides edge-level protection—unauthorized users can't even render protected pages.

Developer Experience

Server actions provide a clean API for authentication. Type safety with TypeScript and Zod ensures reliability. The service layer pattern makes testing straightforward. Middleware centralizes route protection logic in one place.

Scalability

Supabase handles the complexity of auth infrastructure. You can focus on business logic while Supabase manages sessions, security, and compliance.

Progressive Enhancement

Forms work without JavaScript. Users get instant feedback when JS is available. The experience degrades gracefully.

The Cons

Complexity

Managing two databases adds operational overhead. Rollback logic increases code complexity. You need to handle edge cases carefully.

Tight Coupling

The implementation is closely tied to Next.js server actions. Migrating to another framework requires significant refactoring. Supabase becomes a critical dependency.

Transaction Limitations

Cross-database transactions require manual coordination. There's a risk window between auth user creation and profile creation. Rollback operations aren't guaranteed to succeed.

Learning Curve

Understanding server actions takes time. Debugging server-side issues is harder than client-side. The dual-database model requires careful mental mapping. Middleware adds another layer of complexity with its own gotchas around cookie handling.

Middleware Gotchas

Cookie propagation must be perfect—one mistake and users get logged out randomly. You must call getClaims() in every middleware run, adding latency to requests. Debugging middleware issues is particularly challenging since errors happen before page rendering.

Best Practices I Learned

Always Validate Twice

Validate on the client for UX and on the server for security. Never trust client-side data.

Implement Comprehensive Error Handling

Return structured errors with context. Log failures for debugging. Provide user-friendly error messages.

Use Typed Responses

Define clear response types for consistency. Make success and failure states explicit. Leverage TypeScript for compile-time safety.

Test the Rollback Path

Simulate database failures in testing. Verify orphaned users are cleaned up. Monitor rollback operation success rates.

Never modify Supabase cookies in middleware—only propagate them. Always call getClaims() before any authorization logic. Return the original supabaseResponse object to maintain session integrity.

Common Pitfalls to Avoid

Don't skip validation on the server side—client validation is for UX only. Handle the case where rollback operations fail. Consider what happens if the user closes the browser mid-signup. Test your implementation with slow network conditions.

Middleware-specific pitfalls: Never create the Supabase client globally in middleware—always create a new instance per request. Don't run complex logic between createServerClient and getClaims()—this can cause session issues. Avoid overly complex route matching patterns—simple startsWith() checks are more maintainable.

What's Missing: Security Enhancements to Consider

While this implementation provides a solid foundation, there are several security enhancements that would take it from good to great. Let me be transparent about what's not included and why you might want to add these features.

Critical: The Orphaned User Problem

The current rollback mechanism has a weakness—it's not truly atomic. If the network fails or the admin delete operation fails, you end up with an orphaned auth user:

// Current implementation
try {
  await this.db.insert(users).values(newUser);
  return { success: true, data: authUser };
} catch (dbError) {
  const supabaseAdmin = createAdminClient();
  const { error: deleteError } = 
    await supabaseAdmin.auth.admin.deleteUser(authUser.id);
  
  if (deleteError) {
    // ⚠️ Problem: User exists in Supabase but not in our DB
    // They can't log in, but their account exists
  }
}

Solution 1: Cleanup Job

Implement a periodic cleanup job that finds and fixes orphaned users:

// Run this as a cron job every hour
async function cleanupOrphanedUsers() {
  const supabaseAdmin = createAdminClient();
  
  // Get all Supabase users
  const { data: authUsers } = await supabaseAdmin.auth.admin.listUsers();
  
  // Check which ones don't have database profiles
  const orphanedUsers = [];
  for (const authUser of authUsers.users) {
    const dbUser = await db.query.users.findFirst({
      where: eq(users.supabaseUserId, authUser.id)
    });
    
    if (!dbUser) {
      orphanedUsers.push(authUser.id);
    }
  }
  
  // Delete orphaned auth users
  for (const userId of orphanedUsers) {
    await supabaseAdmin.auth.admin.deleteUser(userId);
    console.log(`Cleaned up orphaned user: ${userId}`);
  }
}

Solution 2: Idempotency Tokens

Allow users to safely retry the signup operation:

export async function signUpUser(
  prevState: AuthUserForm | undefined,
  formData: FormData
) {
  const idempotencyKey = formData.get("idempotency-key") as string;
  
  // Check if we've already processed this request
  const existingAttempt = await db.query.signupAttempts.findFirst({
    where: eq(signupAttempts.idempotencyKey, idempotencyKey)
  });
  
  if (existingAttempt) {
    if (existingAttempt.status === 'completed') {
      // Already succeeded, redirect them
      return redirect("/customer/dashboard");
    }
    if (existingAttempt.status === 'failed') {
      // Allow retry with same key
    }
  }
  
  // Continue with signup...
}

Important: Rate Limiting

Currently, there's nothing stopping someone from attempting thousands of login attempts. This is a significant vulnerability.

Implementation with Upstash Redis:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "1 h"), // 5 attempts per hour
  analytics: true,
});

export async function signInUser(
  prevState: AuthUserForm | undefined,
  formData: FormData
) {
  const email = formData.get("email") as string;
  
  // Check rate limit
  const { success, remaining } = await ratelimit.limit(
    `signin_${email.toLowerCase()}`
  );
  
  if (!success) {
    return {
      success: false,
      data: { email },
      error: {
        message: `Too many login attempts. Please try again later. (${remaining} attempts remaining)`,
      },
    };
  }
  
  // Continue with normal sign-in logic...
}

Alternative: Simple in-memory rate limiting (for development):

const loginAttempts = new Map();

function checkRateLimit(email: string): boolean {
  const key = email.toLowerCase();
  const now = Date.now();
  const record = loginAttempts.get(key);
  
  if (!record || now > record.resetAt) {
    loginAttempts.set(key, { count: 1, resetAt: now + 3600000 }); // 1 hour
    return true;
  }
  
  if (record.count >= 5) {
    return false; // Rate limited
  }
  
  record.count++;
  return true;
}

Important: Audit Logging

For compliance and security monitoring, you should log authentication events. This is especially critical for admin actions.

Implementation:

// Create an audit log table
export const auditLogs = pgTable('audit_logs', {
  id: serial('id').primaryKey(),
  userId: text('user_id'),
  action: text('action').notNull(), // 'login', 'logout', 'signup', 'role_change', etc.
  ipAddress: text('ip_address'),
  userAgent: text('user_agent'),
  success: boolean('success').notNull(),
  metadata: jsonb('metadata'), // Additional context
  createdAt: timestamp('created_at').defaultNow(),
});

// Add to your auth service
async function logAuthEvent(event: {
  userId?: string;
  action: string;
  success: boolean;
  metadata?: any;
  request?: NextRequest;
}) {
  await db.insert(auditLogs).values({
    userId: event.userId,
    action: event.action,
    success: event.success,
    ipAddress: event.request?.ip || event.request?.headers.get('x-forwarded-for'),
    userAgent: event.request?.headers.get('user-agent'),
    metadata: event.metadata,
  });
}

// Use it in your sign-in action
export async function signInUser(
  prevState: AuthUserForm | undefined,
  formData: FormData
) {
  const result = await authService.signInUser(validatedFields);
  
  // Log the attempt
  await logAuthEvent({
    userId: result.data?.id,
    action: 'signin',
    success: result.success,
    metadata: { email: validatedFields.email },
  });
  
  if (!result.success) {
    return {
      success: false,
      error: { message: "Invalid email or password" },
    };
  }
  
  // Continue...
}

What to log:

  • All login attempts (successful and failed)
  • Signup events
  • Password changes
  • Role changes (especially important)
  • Admin actions
  • Session revocations
  • Failed authorization attempts (someone trying to access admin routes)

Important: Session Management

Users should be able to see their active sessions and revoke them. This is especially important if they lose a device or suspect their account is compromised.

Implementation approach:

// Store sessions in your database
export const sessions = pgTable('sessions', {
  id: serial('id').primaryKey(),
  userId: text('user_id').notNull(),
  deviceInfo: text('device_info'),
  ipAddress: text('ip_address'),
  lastActive: timestamp('last_active').defaultNow(),
  createdAt: timestamp('created_at').defaultNow(),
});

// Create a sessions page
export default async function SessionsPage() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  
  const activeSessions = await db.query.sessions.findMany({
    where: eq(sessions.userId, user?.id),
    orderBy: desc(sessions.lastActive),
  });
  
  return (
    
      Active Sessions
      {activeSessions.map((session) => (
        
          {session.deviceInfo}
          Last active: {session.lastActive}
          
            
            Revoke
          
        
      ))}
    
  );
}

// Server action to revoke a session
async function revokeSession(formData: FormData) {
  const sessionId = formData.get('sessionId');
  const supabase = await createClient();
  
  // Delete from database
  await db.delete(sessions).where(eq(sessions.id, sessionId));
  
  // If it's the current session, sign them out
  await supabase.auth.signOut();
}

Nice-to-Have: Password Strength Requirements

While Zod validates that passwords exist, there's no enforcement of complexity. Supabase has some defaults, but you should make your requirements explicit:

export const signUpFormSchema = 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 at least one uppercase letter")
    .regex(/[a-z]/, "Password must contain at least one lowercase letter")
    .regex(/[0-9]/, "Password must contain at least one number")
    .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
});

Add a password strength indicator in your UI:

function PasswordStrengthIndicator({ password }: { password: string }) {
  const checks = [
    { label: "At least 8 characters", valid: password.length >= 8 },
    { label: "Contains uppercase", valid: /[A-Z]/.test(password) },
    { label: "Contains lowercase", valid: /[a-z]/.test(password) },
    { label: "Contains number", valid: /[0-9]/.test(password) },
    { label: "Contains special character", valid: /[^A-Za-z0-9]/.test(password) },
  ];
  
  return (
    
      {checks.map((check) => (
        
          {check.valid ? "" : ""} {check.label}
        
      ))}
    
  );
}

Nice-to-Have: Two-Factor Authentication

Especially for admin accounts, 2FA significantly improves security. Supabase supports this out of the box:

// Enable 2FA for a user
async function enableTwoFactor() {
  const supabase = await createClient();
  
  // Enroll the user in MFA
  const { data, error } = await supabase.auth.mfa.enroll({
    factorType: 'totp',
  });
  
  if (error) {
    console.error('Error enrolling in MFA:', error);
    return;
  }
  
  // Show QR code to user
  return {
    qrCode: data.totp.qr_code,
    secret: data.totp.secret,
  };
}

// Verify 2FA code
async function verifyTwoFactor(code: string, factorId: string) {
  const supabase = await createClient();
  
  const { data, error } = await supabase.auth.mfa.challengeAndVerify({
    factorId,
    code,
  });
  
  if (error) {
    return { success: false, error: error.message };
  }
  
  return { success: true };
}

// Enforce 2FA for admins in middleware
export async function updateSession(request: NextRequest) {
  // ... existing code ...
  
  const isAdmin = userRole === 'super_admin' || userRole === 'admin';
  const mfaLevel = data?.aal; // Authentication Assurance Level
  
  // Require 2FA for admin routes
  if (isAdmin && isProtectedAdminRoute && mfaLevel !== 'aal2') {
    const url = request.nextUrl.clone();
    url.pathname = "/auth/mfa-required";
    return NextResponse.redirect(url);
  }
  
  // ... rest of middleware ...
}

Nice-to-Have: Email Verification Flow

Supabase supports email verification, but it's not enforced in the current implementation:

// Update signup to send verification email
const { data: authData, error: authError } = 
  await this.supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/verify`,
    },
  });

// Check email verification in middleware
export async function updateSession(request: NextRequest) {
  const { data } = await supabase.auth.getClaims();
  const user = data?.claims;
  const emailVerified = user?.email_verified;
  
  // Redirect unverified users
  if (user && !emailVerified && isProtectedRoute) {
    const url = request.nextUrl.clone();
    url.pathname = "/auth/verify-email";
    return NextResponse.redirect(url);
  }
  
  // ... rest of middleware ...
}

// Create a resend verification email action
export async function resendVerificationEmail() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  
  if (!user?.email) {
    return { success: false, error: "No email found" };
  }
  
  const { error } = await supabase.auth.resend({
    type: 'signup',
    email: user.email,
  });
  
  if (error) {
    return { success: false, error: error.message };
  }
  
  return { success: true };
}

Implementation Priority

If I were adding these features, here's the order I'd tackle them:

  1. Rate limiting - Quick to implement, immediately improves security
  2. Audit logging - Essential for debugging and compliance
  3. Password strength - Low effort, significant security improvement
  4. Email verification - Supabase makes this easy, prevents spam signups
  5. Orphaned user cleanup - Important for data consistency
  6. Session management - Nice UX improvement, good for security
  7. Two-factor auth - Most complex, but highest security value for admin accounts

Remember, perfect is the enemy of good. The current implementation is production-ready for most use cases. These enhancements move it from "good" to "excellent," but add complexity. Choose the ones that align with your security requirements and user needs.

When to Use This Approach

This architecture works well for applications requiring robust authentication, role-based access control, and complex user relationships. It's ideal when you need custom user data beyond what auth providers offer, when security is paramount, and when you want edge-level route protection via middleware.

Perfect for:

  • MVPs and early-stage startups that need solid auth quickly
  • Internal tools and B2B applications
  • SaaS products with moderate security requirements
  • Applications where you want full control over the auth flow

If you implement the security enhancements from the previous section (especially rate limiting, audit logging, and 2FA), this architecture scales to handle even high-security use cases.

When to Look Elsewhere

For simple projects, complex auth solutions are often unnecessary. If your app doesn't require role-based access control or fine-grained permissions, stick with straightforward authentication—just checking whether users are logged in may be all you need.

For SPAs without server-side rendering, you can handle auth flows client-side (login forms, token storage, route protection), but remember: always validate tokens and enforce permissions on your backend API. Client-side checks are only for user experience—hiding buttons or redirecting routes. Real security happens server-side.

In short: Match your auth complexity to your actual requirements. Simple apps deserve simple solutions, but never compromise on server-side security validation.

Conclusion

Building server-side authentication with Next.js and Supabase provides a secure, scalable foundation for modern web applications. The combination of server actions for authentication logic and middleware for route protection creates a defense-in-depth strategy that's hard to bypass.

While the dual-database approach adds complexity, it offers flexibility in data modeling and business logic implementation. The middleware layer ensures that security checks happen at the edge, before any of your application code runs.

The key is understanding the tradeoffs. More control means more responsibility. Better security requires more careful implementation. The patterns I've shared here work well for production applications, but they require thoughtful implementation and thorough testing—especially around cookie handling in middleware and transaction rollback in the auth service.

Is this implementation perfect? No—and I've been transparent about the security enhancements that would make it even better. Rate limiting, audit logging, and better handling of orphaned users are all valuable additions. But perfection is the enemy of shipping. This implementation provides a solid foundation that you can iterate on as your security requirements evolve.

If you're building a Next.js application that needs robust authentication, this architecture provides a solid starting point. Just remember to test thoroughly, handle errors gracefully, respect the middleware cookie rules, and always prioritize security over convenience. As your application grows, revisit the security enhancements section and implement the ones that align with your needs.


Have you implemented server-side auth in your Next.js projects? What patterns have worked well for you? More importantly—which security enhancements from this post do you consider essential, and which are nice-to-haves for your use case? I'd love to hear about your experiences and any improvements you'd suggest to this approach.

What would you implement first: rate limiting, audit logging, or 2FA?