June 15, 2026·9 min read·By Innovibe

How to Add Auth to Next.js Without NextAuth (JWT + Cookies)

NextAuth is overkill for many apps. Here's a clean, minimal JWT authentication implementation for Next.js that you fully control.

Next.jsAuthJWTTutorial

NextAuth adds magic and abstraction, which is great until it doesn't work the way you need. Here's a lean, production-ready auth implementation using JWTs and HTTP-only cookies — ~200 lines of code, no framework.

The approach

  1. User submits email + password
  2. Server validates, creates a JWT, sets it as an HTTP-only cookie
  3. Every request: middleware checks the cookie, attaches user to request
  4. Logout: clear the cookie

No third-party auth library. No OAuth complexity (unless you add it). Full control.

Step 1: Install dependencies

npm install jose bcryptjs
npm install -D @types/bcryptjs

jose is a modern, edge-compatible JWT library. bcryptjs for password hashing.

Step 2: JWT utilities

// lib/auth/jwt.ts
import { SignJWT, jwtVerify } from 'jose'

const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
const ALGORITHM = 'HS256'

export interface JWTPayload {
  userId: string
  email: string
  role: 'user' | 'admin'
}

export async function signJWT(payload: JWTPayload): Promise<string> {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: ALGORITHM })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(SECRET)
}

export async function verifyJWT(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, SECRET)
    return payload as unknown as JWTPayload
  } catch {
    return null
  }
}

Step 3: Cookie helpers

// lib/auth/cookies.ts
import { cookies } from 'next/headers'
import { signJWT, verifyJWT, JWTPayload } from './jwt'

const COOKIE_NAME = 'auth_token'

export async function setAuthCookie(payload: JWTPayload) {
  const token = await signJWT(payload)
  cookies().set(COOKIE_NAME, token, {
    httpOnly: true,       // not accessible via JS — XSS protection
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',      // CSRF protection
    maxAge: 60 * 60 * 24 * 7,  // 7 days
    path: '/',
  })
}

export async function getAuthUser(): Promise<JWTPayload | null> {
  const token = cookies().get(COOKIE_NAME)?.value
  if (!token) return null
  return verifyJWT(token)
}

export function clearAuthCookie() {
  cookies().delete(COOKIE_NAME)
}

Step 4: Login route

// app/api/auth/login/route.ts
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { db } from '@/lib/db'
import { setAuthCookie } from '@/lib/auth/cookies'

export async function POST(req: Request) {
  const { email, password } = await req.json()

  if (!email || !password) {
    return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
  }

  const user = await db.user.findUnique({ where: { email } })
  if (!user) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
  }

  const valid = await bcrypt.compare(password, user.passwordHash)
  if (!valid) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
  }

  await setAuthCookie({ userId: user.id, email: user.email, role: user.role })

  return NextResponse.json({ ok: true })
}

Step 5: Middleware to protect routes

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyJWT } from '@/lib/auth/jwt'

const PUBLIC_ROUTES = ['/', '/login', '/register', '/api/auth']

export async function middleware(req: NextRequest) {
  const isPublic = PUBLIC_ROUTES.some(route => 
    req.nextUrl.pathname.startsWith(route)
  )
  if (isPublic) return NextResponse.next()

  const token = req.cookies.get('auth_token')?.value
  if (!token) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  const user = await verifyJWT(token)
  if (!user) {
    const response = NextResponse.redirect(new URL('/login', req.url))
    response.cookies.delete('auth_token')
    return response
  }

  // Attach user to headers for Server Components to read
  const requestHeaders = new Headers(req.headers)
  requestHeaders.set('x-user-id', user.userId)
  requestHeaders.set('x-user-email', user.email)
  requestHeaders.set('x-user-role', user.role)

  return NextResponse.next({ request: { headers: requestHeaders } })
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Step 6: Read the user in Server Components

// lib/auth/user.ts
import { headers } from 'next/headers'

export function getCurrentUser() {
  const headersList = headers()
  const userId = headersList.get('x-user-id')
  if (!userId) return null
  return {
    userId,
    email: headersList.get('x-user-email')!,
    role: headersList.get('x-user-role') as 'user' | 'admin',
  }
}

// Usage in any Server Component:
export default async function DashboardPage() {
  const user = getCurrentUser()
  if (!user) redirect('/login')
  return <div>Welcome, {user.email}</div>
}

Step 7: Logout

// app/api/auth/logout/route.ts
import { NextResponse } from 'next/server'
import { clearAuthCookie } from '@/lib/auth/cookies'

export async function POST() {
  clearAuthCookie()
  return NextResponse.json({ ok: true })
}

That's it. No magic, no hidden behavior, full control over the auth flow.

When to use this vs NextAuth:

  • This: you want control, you have one auth method, you don't need OAuth providers
  • NextAuth: you need Google/GitHub/Twitter login, you want session management handled for you

Building a Next.js product and need auth or backend architecture? Get in touch.

K
Innovibe
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

When should I use Server Components vs Client Components?+

Server Components are the default — use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Should I use the App Router or Pages Router for a new project?+

App Router for new projects, full stop. Pages Router still works fine but receives fewer improvements. The learning curve for App Router is steep for one week, then you never want to go back.

How long will this take to implement?+

Most readers ship a working version in an afternoon. The first implementation is always the hardest — once you understand the pattern, iterations get much faster.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation