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:
- Multiple OAuth providers (Google, GitHub, etc.)
- Magic link authentication
- JWT and database sessions
- Built-in security best practices
- TypeScript support
- Edge runtime compatible
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:
- Extend the user model to fit your app's needs
- Always validate server-side – never trust the client
- Implement proper RBAC for different user types
- Log authentication events for security auditing
- 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.