Skip to main content
Authentication in Next.js involves three concepts:
  1. Authentication — Verifies the user’s identity (username/password, OAuth, etc.)
  2. Session management — Tracks auth state across requests using cookies or a database
  3. Authorization — Determines what routes and data the authenticated user can access
For increased security and simplicity, use an auth library rather than building your own solution from scratch.

Sign-up and login

Use the HTML <form> element with Server Actions and useActionState to capture credentials, validate fields, and call your auth provider.
1

Capture user credentials

Create a form that invokes a Server Action on submission:
'use client'

import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'

export default function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined)

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <button disabled={pending} type="submit">
        Sign Up
      </button>
    </form>
  )
}
2

Validate form fields on the server

Use Zod or a similar schema library to validate form fields server-side:
import * as z from 'zod'

export const SignupFormSchema = z.object({
  name: z.string().min(2, { error: 'Name must be at least 2 characters long.' }).trim(),
  email: z.email({ error: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { error: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { error: 'Contain at least one letter.' })
    .regex(/[0-9]/, { error: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, { error: 'Contain at least one special character.' })
    .trim(),
})

export type FormState =
  | {
      errors?: {
        name?: string[]
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined
import { SignupFormSchema, FormState } from '@/app/lib/definitions'

export async function signup(state: FormState, formData: FormData) {
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  if (!validatedFields.success) {
    return { errors: validatedFields.error.flatten().fieldErrors }
  }

  // Call the provider or db to create a user...
}
3

Create a user or check credentials

After validation, insert the user or check credentials against your database:
export async function signup(state: FormState, formData: FormData) {
  // 1. Validate form fields
  // ...

  // 2. Hash the password
  const { name, email, password } = validatedFields.data
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. Insert the user into the database
  const data = await db
    .insert(users)
    .values({ name, email, password: hashedPassword })
    .returning({ id: users.id })

  const user = data[0]
  if (!user) {
    return { message: 'An error occurred while creating your account.' }
  }

  // 4. Create user session
  await createSession(user.id)
  // 5. Redirect user
  redirect('/profile')
}

Session management

There are two types of sessions:
  • Stateless — Session data (JWT) stored in the browser’s cookies. Simpler but must be implemented carefully.
  • Database — Session data stored server-side; only an encrypted session ID is stored in the cookie.
Use a session management library like iron-session or Jose rather than managing encryption yourself.

Stateless sessions

1

Generate a secret key

openssl rand -base64 32
Store it as an environment variable:
SESSION_SECRET=your_secret_key
2

Encrypt and decrypt sessions

import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@/app/lib/definitions'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('Failed to verify session')
  }
}
3

Set session cookies

import 'server-only'
import { cookies } from 'next/headers'

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
Recommended cookie options:
  • httpOnly — Prevents client-side JavaScript from accessing the cookie
  • secure — Only send over HTTPS
  • sameSite — Controls cross-site request behavior
  • expires / maxAge — Automatic cookie expiry
4

Update and delete sessions

export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)

  if (!session || !payload) return null

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires,
    sameSite: 'lax',
    path: '/',
  })
}

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
Use deleteSession() on logout:
export async function logout() {
  await deleteSession()
  redirect('/login')
}

Authorization

Middleware (optimistic checks)

Use middleware for fast, optimistic route protection based on session cookies. Because middleware runs on every route, only perform cookie-based checks here — avoid database queries to prevent performance issues.
import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'

const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']

export default async function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  if (isPublicRoute && session?.userId && !req.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
Middleware should not be your only line of defense. Perform auth checks as close to your data source as possible.

Data Access Layer (DAL)

Centralize your authorization logic in a DAL with a verifySession() function:
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
import { cache } from 'react'

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session?.userId) {
    redirect('/login')
  }

  return { isAuth: true, userId: session.userId }
})

export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  const data = await db.query.users.findMany({
    where: eq(users.id, session.userId),
    columns: { id: true, name: true, email: true },
  })

  return data[0]
})
Call verifySession() in Server Components, Server Actions, and Route Handlers:
import { verifySession } from '@/app/lib/dal'

export default async function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role

  if (userRole === 'admin') return <AdminDashboard />
  if (userRole === 'user') return <UserDashboard />
  redirect('/login')
}

Data Transfer Objects (DTO)

Only return the minimum data needed. Use DTOs to filter fields based on viewer permissions:
import 'server-only'
import { getUser } from '@/app/lib/dal'

export async function getProfileDTO(slug: string) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
  })
  const user = data[0]
  const currentUser = await getUser(user.id)

  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team) ? user.phonenumber : null,
  }
}

Auth libraries

Rather than building your own solution, consider these authentication libraries:

NextAuth.js

Full-featured authentication with OAuth, email/password, and JWT support.

Clerk

Hosted auth with pre-built UI components and extensive customization.

Auth0

Enterprise-grade auth platform with social logins and MFA.

Kinde

Simple auth for modern web apps with built-in multi-tenancy.

Build docs developers (and LLMs) love