Authentication in Next.js: Beyond the Basics with NextAuth.js

A comprehensive guide to implementing robust authentication in Next.js applications using NextAuth.js, including session management, role-based access control, and security best practices.

Nischal Timalsina
nextjsauthenticationsecuritynextauth
9 min read

Authentication in Next.js: Beyond the Basics with NextAuth.js

Authentication is one of those features that seems simple until you actually implement it. After building auth for Appointree – supporting multiple OAuth providers, role-based access, and multi-tenancy – I've learned that good authentication is about much more than just checking if someone is logged in.

Why NextAuth.js?

NextAuth.js (now Auth.js) handles the complexity of modern authentication:

But the real power comes from how you configure it.

Basic Setup

First, the foundation:

// lib/auth.ts
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import { MongoDBAdapter } from '@auth/mongodb-adapter'
import clientPromise from '@/lib/mongodb'

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: MongoDBAdapter(clientPromise),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  pages: {
    signIn: '/auth/signin',
    error: '/auth/error',
  },
  session: {
    strategy: 'jwt', // or 'database'
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
})

Extended User Model

The default NextAuth user is too basic for most apps. Extend it:

// types/next-auth.d.ts
import 'next-auth'
import type { DefaultSession } from 'next-auth'

declare module 'next-auth' {
  interface Session {
    user: {
      id: string
      tenantId: string
      role: 'owner' | 'admin' | 'staff' | 'customer'
      isSuspended: boolean
    } & DefaultSession['user']
  }

  interface User {
    tenantId: string
    role: 'owner' | 'admin' | 'staff' | 'customer'
    isSuspended: boolean
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    id: string
    tenantId: string
    role: 'owner' | 'admin' | 'staff' | 'customer'
    isSuspended: boolean
  }
}

Advanced NextAuth Configuration

// lib/auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: MongoDBAdapter(clientPromise),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // Request specific scopes
      authorization: {
        params: {
          scope: 'openid email profile',
          prompt: 'select_account',
        },
      },
    }),
  ],

  callbacks: {
    // Add custom fields to JWT
    async jwt({ token, user, trigger, session }) {
      if (user) {
        token.id = user.id
        token.tenantId = user.tenantId
        token.role = user.role
        token.isSuspended = user.isSuspended
      }

      // Handle session updates (e.g., profile changes)
      if (trigger === 'update' && session) {
        token.name = session.name
        token.email = session.email
      }

      return token
    },

    // Add custom fields to session
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string
        session.user.tenantId = token.tenantId as string
        session.user.role = token.role as any
        session.user.isSuspended = token.isSuspended as boolean
      }
      return session
    },

    // Control whether user can sign in
    async signIn({ user, account, profile }) {
      // Check if user is suspended
      if (user.isSuspended) {
        return '/auth/error?error=AccountSuspended'
      }

      // Verify email domain for certain providers
      if (account?.provider === 'google') {
        const emailDomain = user.email?.split('@')[1]
        if (emailDomain === 'yourcompany.com') {
          return true
        }
      }

      return true
    },

    // Customize redirect after sign in
    async redirect({ url, baseUrl }) {
      // Redirect to dashboard after sign in
      if (url.startsWith(baseUrl)) return url
      if (url.startsWith('/')) return `${baseUrl}${url}`
      return baseUrl + '/dashboard'
    },
  },

  events: {
    // Track sign in events
    async signIn({ user, account, isNewUser }) {
      if (isNewUser) {
        console.log('New user signed up:', user.email)
        // Send welcome email, create default data, etc.
      }

      // Log sign in
      await db.auditLog.create({
        userId: user.id,
        action: 'sign_in',
        provider: account?.provider,
        timestamp: new Date(),
      })
    },

    async signOut({ token }) {
      // Log sign out
      await db.auditLog.create({
        userId: token.id,
        action: 'sign_out',
        timestamp: new Date(),
      })
    },
  },

  pages: {
    signIn: '/auth/signin',
    error: '/auth/error',
    newUser: '/onboarding',
  },
})

Server-Side Session Validation

// lib/auth/session.ts
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export async function requireAuth() {
  const session = await auth()

  if (!session?.user) {
    redirect('/auth/signin')
  }

  if (session.user.isSuspended) {
    redirect('/auth/suspended')
  }

  return session
}

export async function requireRole(
  allowedRoles: Array<'owner' | 'admin' | 'staff' | 'customer'>
) {
  const session = await requireAuth()

  if (!allowedRoles.includes(session.user.role)) {
    redirect('/unauthorized')
  }

  return session
}

// Usage in Server Components
export default async function AdminPage() {
  const session = await requireRole(['owner', 'admin'])

  return <div>Admin content for {session.user.email}</div>
}

Protected API Routes

// app/api/appointments/route.ts
import { auth } from '@/lib/auth'

export async function GET(req: Request) {
  const session = await auth()

  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  if (session.user.isSuspended) {
    return Response.json({ error: 'Account suspended' }, { status: 403 })
  }

  // Automatically scope to tenant
  const appointments = await db.appointments.find({
    tenantId: session.user.tenantId,
  })

  return Response.json(appointments)
}

Client-Side Session Access

// components/user-menu.tsx
'use client'

import { useSession, signOut } from 'next-auth/react'

export function UserMenu() {
  const { data: session, status } = useSession()

  if (status === 'loading') {
    return <Skeleton className="h-10 w-10 rounded-full" />
  }

  if (!session) {
    return <SignInButton />
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger>
        <Avatar>
          <AvatarImage src={session.user.image} />
          <AvatarFallback>{session.user.name?.[0]}</AvatarFallback>
        </Avatar>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem>
          {session.user.email}
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => signOut()}>
          Sign Out
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Protected Client Components

// components/auth/require-auth.tsx
'use client'

import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

export function RequireAuth({ children }: { children: React.ReactNode }) {
  const { data: session, status } = useSession()
  const router = useRouter()

  useEffect(() => {
    if (status === 'unauthenticated') {
      router.push('/auth/signin')
    }
  }, [status, router])

  if (status === 'loading') {
    return <LoadingSpinner />
  }

  if (!session) {
    return null
  }

  return <>{children}</>
}

// Usage
<RequireAuth>
  <DashboardContent />
</RequireAuth>

Role-Based Access Control

// components/auth/require-role.tsx
'use client'

import { useSession } from 'next-auth/react'
import type { Session } from 'next-auth'

type Role = Session['user']['role']

interface Props {
  allowedRoles: Role[]
  children: React.ReactNode
  fallback?: React.ReactNode
}

export function RequireRole({ allowedRoles, children, fallback }: Props) {
  const { data: session } = useSession()

  if (!session?.user) {
    return null
  }

  if (!allowedRoles.includes(session.user.role)) {
    return fallback || null
  }

  return <>{children}</>
}

// Usage
<RequireRole allowedRoles={['owner', 'admin']}>
  <AdminControls />
</RequireRole>

<RequireRole
  allowedRoles={['owner']}
  fallback={<p>Owner access required</p>}
>
  <DeleteTenantButton />
</RequireRole>

Session Updates

// Update session without full page reload
'use client'

import { useSession } from 'next-auth/react'

export function UpdateProfile() {
  const { update } = useSession()

  async function handleUpdate(name: string) {
    await fetch('/api/user/profile', {
      method: 'PATCH',
      body: JSON.stringify({ name }),
    })

    // Trigger session update
    await update({ name })
  }

  return <form onSubmit={(e) => { /* ... */ }} />
}

Custom Sign In Page

// app/auth/signin/page.tsx
'use client'

import { signIn } from 'next-auth/react'
import { useSearchParams } from 'next/navigation'

export default function SignInPage() {
  const searchParams = useSearchParams()
  const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'
  const error = searchParams.get('error')

  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md space-y-4 p-8">
        <h1 className="text-2xl font-bold">Sign In</h1>

        {error && (
          <Alert variant="destructive">
            {error === 'AccountSuspended' && 'Your account has been suspended.'}
            {error === 'OAuthSignin' && 'Error signing in with OAuth provider.'}
            {error === 'OAuthCallback' && 'Error in OAuth callback.'}
          </Alert>
        )}

        <Button
          onClick={() => signIn('google', { callbackUrl })}
          className="w-full"
        >
          <GoogleIcon className="mr-2" />
          Continue with Google
        </Button>
      </div>
    </div>
  )
}

Security Best Practices

1. CSRF Protection

NextAuth handles CSRF automatically, but verify:

// Ensure cookies are secure
export const authConfig = {
  cookies: {
    sessionToken: {
      name: `__Secure-next-auth.session-token`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },
}

2. Rate Limiting

// lib/rate-limit.ts
import { LRUCache } from 'lru-cache'

const rateLimitCache = new LRUCache({
  max: 500,
  ttl: 60000, // 1 minute
})

export function rateLimit(identifier: string, limit: number = 5) {
  const count = (rateLimitCache.get(identifier) as number) || 0

  if (count >= limit) {
    return false
  }

  rateLimitCache.set(identifier, count + 1)
  return true
}

// In sign-in API route
if (!rateLimit(req.headers.get('x-forwarded-for') || 'anonymous')) {
  return Response.json({ error: 'Too many requests' }, { status: 429 })
}

3. Session Security

// Invalidate sessions on password change
export async function invalidateUserSessions(userId: string) {
  await db.sessions.deleteMany({ userId })

  // If using database sessions
  // If using JWT, sessions will expire naturally
}

4. Audit Logging

// Track all authentication events
const authEvents = {
  async log(event: {
    userId?: string
    action: 'sign_in' | 'sign_out' | 'failed_sign_in'
    provider?: string
    ip?: string
    userAgent?: string
  }) {
    await db.auditLog.create({
      ...event,
      timestamp: new Date(),
    })
  },
}

Testing Authentication

// __tests__/auth.test.ts
import { describe, it, expect, vi } from 'vitest'
import { auth } from '@/lib/auth'

vi.mock('@/lib/auth')

describe('Protected routes', () => {
  it('should redirect unauthenticated users', async () => {
    vi.mocked(auth).mockResolvedValue(null)

    const response = await GET(new Request('http://localhost/api/protected'))
    expect(response.status).toBe(401)
  })

  it('should allow authenticated users', async () => {
    vi.mocked(auth).mockResolvedValue({
      user: {
        id: '1',
        email: 'test@example.com',
        role: 'admin',
        tenantId: 'tenant-1',
      },
    } as any)

    const response = await GET(new Request('http://localhost/api/protected'))
    expect(response.status).toBe(200)
  })
})

Common Pitfalls

1. Not validating sessions server-side

Never trust client-side session state. Always validate on the server for protected actions.

// ❌ Bad - relies on client
'use client'
function DeleteButton() {
  const { data: session } = useSession()
  if (session?.user.role !== 'admin') return null
  return <button onClick={handleDelete}>Delete</button>
}

// ✅ Good - validates server-side
async function handleDelete() {
  const res = await fetch('/api/delete', { method: 'DELETE' })
  // API route validates role server-side
}

2. Exposing sensitive user data in session

Don't put everything in the JWT – it's sent with every request and decoded client-side.

// ❌ Bad
token.creditCard = user.creditCard

// ✅ Good
token.userId = user.id // Fetch sensitive data when needed

3. Not handling session expiration

Sessions expire. Handle it gracefully.

'use client'
import { useSession } from 'next-auth/react'

export function SessionWatcher() {
  const { data: session, status } = useSession()

  useEffect(() => {
    if (status === 'unauthenticated') {
      toast.error('Your session has expired. Please sign in again.')
      router.push('/auth/signin')
    }
  }, [status])

  return null
}

Conclusion

Good authentication is invisible to users but critical for security. Key takeaways:

  1. Extend the user model to fit your app's needs
  2. Always validate server-side – never trust the client
  3. Implement proper RBAC for different user types
  4. Log authentication events for security auditing
  5. Handle edge cases like suspended accounts and expired sessions

NextAuth.js provides the foundation, but your authentication strategy should be tailored to your application's specific security requirements and user experience goals.