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
- User submits email + password
- Server validates, creates a JWT, sets it as an HTTP-only cookie
- Every request: middleware checks the cookie, attaches user to request
- 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.