NextJs

7 Common Next.js Authentication Mistakes (And How to Avoid Them)

Learn the critical authentication mistakes developers make in Next.js and how to fix them. From middleware issues to session management, protect your app properly.

Hasan Hatem

9 min read
3 views

Your Next.js authentication is probably broken. Not in an obvious way—your login form works, sessions persist, and everything seems fine. Until a security researcher points out that your protected API routes are wide open.

I've reviewed hundreds of Next.js codebases, and the same authentication mistakes appear repeatedly. Even experienced developers fall into these traps because Next.js's hybrid rendering model makes authentication surprisingly complex.

Let's fix the most critical mistakes before they become security incidents.


Quick Answer: The 7 Deadly Sins of Next.js Auth

Most Common Mistakes:

  1. Only checking auth on the client side (server routes remain exposed)
  2. Using middleware for everything (static routes bypass it entirely)
  3. Storing tokens in localStorage (XSS vulnerability waiting to happen)
  4. Forgetting to protect Server Actions (they're publicly callable URLs)
  5. Implementing auth in Layout components (they don't re-render on navigation)
  6. Missing CSRF protection (especially with custom auth)
  7. Returning full user objects to the client (exposing sensitive data)

If you've made any of these mistakes, you're not alone. Let's dive into each one and fix them properly.


Mistake #1: Client-Side Only Authentication

The Problem:

// ❌ WRONG: This only hides the UI, doesn't protect data
'use client'
export default function Dashboard() {
  const { user } = useAuth()

  if (!user) return <LoginPrompt />

  return <SensitiveData />
}

This conditionally renders UI based on auth state, but anyone can directly call your API endpoints to fetch protected data. Client-side checks are cosmetic—they don't enforce security.

The Fix:

Always verify authentication on the server where data is accessed:

// ✅ CORRECT: Verify auth in Server Component
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export default async function Dashboard() {
  const session = await auth()

  if (!session) redirect('/login')

  const userData = await fetchUserData(session.user.id)
  return <DashboardView data={userData} />
}

Why This Matters:

Client-side JavaScript can be bypassed by simply disabling JavaScript or making direct API requests. Server-side checks are the only real security boundary.


Mistake #2: Relying Solely on Middleware

The Problem:

Many developers put all authentication logic in middleware, thinking it protects every route:

// ❌ INCOMPLETE: Middleware doesn't run on static routes
export async function middleware(request) {
  const session = await getSession(request)
  if (!session) return redirect('/login')
}

Here's the catch: Middleware doesn't execute for static routes, cached pages, or certain edge cases. It also runs before your app code, limiting access to database queries and business logic.

The Fix:

Use a Data Access Layer (DAL) pattern that centralizes auth checks with data retrieval:

// ✅ CORRECT: Auth check in Data Access Layer
import { cache } from 'react'
import { auth } from '@/lib/auth'

export const verifySession = cache(async () => {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
  return { isAuth: true, userId: session.user.id }
})

export async function getUserData() {
  const session = await verifySession() // Auth check happens here
  return prisma.user.findUnique({ where: { id: session.userId } })
}

Now every data access automatically includes authentication verification, regardless of how the route renders.


Mistake #3: Storing Tokens in localStorage

The Problem:

// ❌ DANGER: Accessible by any JavaScript (XSS vulnerability)
localStorage.setItem('token', authToken)

Any third-party script, browser extension, or XSS vulnerability can steal tokens from localStorage. It's essentially handing your authentication credentials to potential attackers.

The Fix:

Use httpOnly cookies that JavaScript can't access:

// ✅ SECURE: Server-side cookie with proper flags
import { cookies } from 'next/headers'

export async function setAuthCookie(token: string) {
  cookies().set('session', token, {
    httpOnly: true,      // JavaScript can't access
    secure: true,        // HTTPS only
    sameSite: 'lax',     // CSRF protection
    maxAge: 60 * 60 * 24 * 7, // 7 days
  })
}

Why This Matters:

According to OWASP, XSS remains one of the top web vulnerabilities. HttpOnly cookies eliminate an entire attack vector.


Mistake #4: Unprotected Server Actions

The Problem:

Server Actions are special—they generate publicly callable URLs. Developers often forget they need independent authentication:

// ❌ EXPOSED: Anyone can call this directly
'use server'
export async function deleteUser(userId: string) {
  await prisma.user.delete({ where: { id: userId } })
}

An attacker can find the Server Action URL and call it directly, bypassing your page-level auth checks.

The Fix:

Always verify authentication inside Server Actions:

// ✅ PROTECTED: Auth check within the action
'use server'
import { auth } from '@/lib/auth'

export async function deleteUser(userId: string) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
  if (session.user.role !== 'admin') throw new Error('Forbidden')

  await prisma.user.delete({ where: { id: userId } })
}

Pro Tip: Also validate input data. Server Actions are API endpoints in disguise—treat them like it.


Mistake #5: Authentication in Layout Components

The Problem:

// ❌ BROKEN: Layout doesn't re-render on navigation
export default async function DashboardLayout({ children }) {
  const session = await auth()
  if (!session) redirect('/login')

  return <div>{children}</div>
}

Layouts in Next.js don't re-render when navigating between pages in the same layout. This creates a race condition where users might access protected pages after logging out.

The Fix:

Implement auth checks in individual pages or use middleware for layout-level protection:

// ✅ BETTER: Check auth in each page
export default async function SettingsPage() {
  const session = await auth()
  if (!session) redirect('/login')

  return <SettingsForm />
}

Or combine middleware for layout protection with page-level checks for critical routes.


Mistake #6: Missing CSRF Protection

The Problem:

If you're building custom authentication (not using NextAuth/Auth.js), you might forget Cross-Site Request Forgery protection:

// ❌ VULNERABLE: No CSRF token verification
export async function POST(request: Request) {
  const { email, password } = await request.json()
  const user = await authenticate(email, password)
  // Anyone can trigger this from another site
}

The Fix:

Implement CSRF tokens for state-changing operations:

// ✅ PROTECTED: Verify CSRF token
import { verifyCSRFToken } from '@/lib/csrf'

export async function POST(request: Request) {
  const csrfToken = request.headers.get('X-CSRF-Token')
  if (!verifyCSRFToken(csrfToken)) {
    return new Response('Invalid CSRF token', { status: 403 })
  }

  // Process request safely
}

Note: Auth.js/NextAuth handles CSRF automatically. If you use it, you're already protected.


Mistake #7: Exposing Full User Objects

The Problem:

// ❌ LEAKING DATA: Sending everything to client
export async function getUser(id: string) {
  const user = await prisma.user.findUnique({
    where: { id }
  })
  return user // Includes password hash, internal IDs, etc.
}

Even if you hash passwords, exposing internal database fields gives attackers valuable information for social engineering or privilege escalation.

The Fix:

Use Data Transfer Objects (DTOs) to control exactly what data leaves the server:

// ✅ SAFE: Only return necessary fields
export async function getUser(id: string) {
  const user = await prisma.user.findUnique({
    where: { id },
    select: {
      id: true,
      name: true,
      email: true,
      image: true,
      // Explicitly exclude: passwordHash, role, internalId, etc.
    }
  })
  return user
}

Security Principle: Only expose data the client absolutely needs. When in doubt, leave it out.


Frequently Asked Questions

Should I use middleware or Data Access Layer for auth?

Use both strategically. Middleware for broad protection (redirecting logged-out users) and DAL for data access security. DAL is mandatory because middleware doesn't cover static routes.

Is NextAuth/Auth.js secure by default?

Yes, but you still need to implement proper checks in Server Actions and data access functions. NextAuth handles session management securely, but it can't protect your business logic automatically.

How do I test if my auth is secure?

Log out, then try to directly access API routes using browser DevTools Network tab. If you can fetch protected data, your auth is broken.

What's the difference between authentication and authorization?

Authentication verifies identity ("who are you?"). Authorization verifies permissions ("what can you do?"). You need both—even authenticated users shouldn't access admin routes without proper roles.

Should I build custom auth or use a library?

Use a library (Auth.js, Clerk, Supabase Auth) unless you have very specific requirements. Custom auth is hard to get right and introduces more security risks.

How do I handle token refresh?

Most libraries handle this automatically. If building custom auth, implement refresh tokens with rotation—never store long-lived tokens client-side.


The Pattern: Defense in Depth

Secure Next.js authentication requires multiple layers:

  1. Data Layer: Verify auth where data is accessed (DAL pattern)
  2. Route Layer: Check sessions in page components
  3. UI Layer: Hide sensitive elements from unauthorized users
  4. Action Layer: Validate auth in every Server Action

Each layer protects against different attack vectors. Skip one, and you're vulnerable.


The Faster Solution

Setting up authentication correctly takes weeks of security research, testing edge cases, and implementing these patterns across your entire codebase.

GetNextKit includes production-ready authentication with all these security measures pre-implemented:

  • ✅ Multi-layered auth checks (DAL + route + action)
  • ✅ Secure session management with httpOnly cookies
  • ✅ CSRF protection built-in
  • ✅ Role-based access control
  • ✅ Protected Server Actions with input validation
  • ✅ DTOs for controlled data exposure

Plus, every Pro plan purchase enters you in our $50,000 giveaway (5 developers split the prize when we hit 5,000 sales).

Stop debugging auth issues and start building features users actually want.


Conclusion

Authentication mistakes in Next.js are common because the framework's hybrid rendering makes security non-obvious. Client-side checks feel sufficient until they're not. Middleware seems comprehensive until you learn about static routes.

Remember: Security is multi-layered. Check authentication at every boundary—client-side for UX, server-side for security. Use httpOnly cookies, protect Server Actions, and never expose more data than necessary.

Your future self (and your users) will thank you when the security audit comes back clean.

Next Steps: Review your current auth implementation against these 7 mistakes. Start with Server Actions—they're the most commonly forgotten attack vector.


Related Resources

Share this article

Related Articles

Featured On