Skip to content

Next Auth

Authentication in Next.js is a middleware dance , a session strategy , and a thousand ways to accidentally expose protected routes

Next.js doesn't ship auth. You bring your own. Auth.js (formerly NextAuth.js) is the most popular choice but the setup is non-trivial. One misconfigured middleware matcher and your admin panel is public. One wrong callback URL and your OAuth flow redirects to an attacker's domain. Get it right or get pwned

Auth.js v5 setup

npm install next-auth@beta @auth/core

Create the auth configuration:

// src/lib/auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub,
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type': 'password' }
      },
      async authorize(credentials) {
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string }
        })
        if (!user || !user.password) return null
        // verify password with bcrypt
        const valid = await bcrypt.compare(credentials.password as string, user.password)
        if (!valid) return null
        return user
      }
    })
  ],
  session: {
    strategy: 'jwt' // or 'database'
  }
})

Create the route handler:

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'

export const { GET, POST } = handlers

Auth.js v5 vs v4

Auth.js v5 (the next-auth@beta / @auth/core package) is a complete rewrite

Feature v4 v5
Import next-auth @auth/core + next-auth
API route pages/api/auth/[...nextauth].js app/api/auth/[...nextauth]/route.ts
Session check getServerSession() auth()
Middleware getToken() auth() wrapper
Edge support Limited Full

The auth() function is the v5 replacement for getServerSession(). It's simpler and runs on Edge

middleware-based protection

Protect routes using the auth() middleware wrapper

// src/middleware.ts
import { auth } from '@/lib/auth'

export default auth((req) => {
  const { pathname } = req.nextUrl
  const isLoggedIn = !!req.auth

  // Public routes
  const publicPaths = ['/', '/login', '/register', '/api/auth']
  const isPublic = publicPaths.some(path => pathname.startsWith(path))

  if (!isLoggedIn && !isPublic) {
    return Response.redirect(new URL('/login', req.url))
  }

  // Role-based access
  if (pathname.startsWith('/admin') && req.auth?.user?.role !== 'admin') {
    return Response.redirect(new URL('/unauthorized', req.url))
  }
})

export const config = {
  matcher: [
    // Match all routes except static files , images , and auth API
    '/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)'
  ]
}

The matcher config is critical. If it's too broad , you block static assets. If it's too narrow , you miss protected routes. The pattern above is the standard safe configuration

session handling

Read the session in Server Components:

// src/app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

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

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

  return (
    <div>
      <h1>Welcome, {session.user?.name}</h1>
      <p>Email: {session.user?.email}</p>
      <p>Role: {session.user?.role}</p>
    </div>
  )
}

Read session in Client Components:

'use client'

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

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

  if (status === 'loading') return <div>Loading...</div>
  if (status === 'unauthenticated') return <a href="/login">Login</a>

  return <div>Signed in as {session.user?.name}</div>
}

Wrap your app with the SessionProvider:

// src/app/providers.tsx
'use client'

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

export default function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>
}
// src/app/layout.tsx
import Providers from './providers'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

JWT vs database sessions

JWT sessions: * Token stored in a cookie * No database lookup on every request * Can't invalidate individual sessions (token is valid until expiry) * Token size grows with session data

session: {
  strategy: 'jwt',
  maxAge: 60 * 60 * 24 * 7 // 7 days
}

Database sessions: * Session ID stored in cookie , data in database * Can invalidate sessions (delete from database) * Database lookup on every request * Works with PrismaAdapter automatically

session: {
  strategy: 'database',
  maxAge: 60 * 60 * 24 * 7
}

Pick JWT for serverless deployments where database lookups are expensive. Pick database sessions when you need to revoke access immediately (security incidents , account termination)

role-based access

Extend the JWT or session with custom fields:

// src/lib/auth.ts
export const { handlers, signIn, signOut, auth } = NextAuth({
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
        token.id = user.id
      }
      return token
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.role = token.role as string
        session.user.id = token.id as string
      }
      return session
    }
  }
})

Then check roles in middleware or pages:

// middleware check
if (pathname.startsWith('/admin') && req.auth?.user?.role !== 'admin') {
  return Response.redirect(new URL('/unauthorized', req.url))
}

// page-level check
const session = await auth()
if (session?.user?.role !== 'admin') {
  redirect('/unauthorized')
}

OAuth callback security

OAuth callbacks are a common attack vector. Validate everything:

// Auth.js handles most of this , but configure the callback URL:
NextAuth({
  // Restrict callback URLs (prevents open redirect attacks)
  callbacks: {
    async redirect({ url, baseUrl }) {
      // Only allow redirects to your own domain
      if (url.startsWith(baseUrl)) return url
      // Or relative URLs
      if (url.startsWith('/')) return new URL(url, baseUrl).toString()
      return baseUrl
    }
  }
})

Never trust unvalidated callbackUrl parameters. That's how open redirect vulnerabilities work

prerequisites

next_08_api_routes.md - you should understand route handlers and middleware


next → next_10_security.md