Authentication
JWT and session auth for the App Router — paired route handlers, cookies, and curl commands that carry a session locally
Common authentication strategies for web apps.
Unlike plain data endpoints, auth carries a session across requests. In the
curl examples below, -c cookies.txt saves the cookie the server
sets, and -b cookies.txt sends it back — so you can log in once and
reuse the session for the next request, exactly like a browser.
Setup
These examples use jose for JWTs (Web Crypto,
so the same code runs in route handlers and Edge middleware) and bcryptjs
for password hashing:
pnpm add jose bcryptjs
# .env.local — a 32+ char secret used to sign tokens
JWT_SECRET=replace-with-a-long-random-string-min-32-chars
Token Helpers
One module signs and verifies tokens so every route stays consistent.
// lib/auth.ts
import { SignJWT, jwtVerify } from "jose"
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
export type SessionPayload = { userId: number; email: string }
export async function signToken(payload: SessionPayload): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secret)
}
export async function verifyToken(
token: string
): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, secret)
return payload as SessionPayload
} catch {
return null // expired, tampered, or malformed
}
}
Signup — Hash the Password
Never store raw passwords. Hash on the way in; the plaintext never touches your data store.
Login — Verify & Set an httpOnly Cookie
Compare the password against the stored hash, then set the token in an
httpOnly cookie so client-side JavaScript (and XSS) can't read it.
Protected Route — Read the Session
Read the token from the cookie (or a Bearer header for API clients), verify
it, and return 401 when it's missing or invalid.
Logout — Clear the Cookie
Overwrite the cookie with an immediate expiry.
Guarding Pages with Middleware
Middleware runs before the request reaches a route — redirect unauthenticated
users away from protected pages. Because jose uses Web Crypto, verifyToken
works here in the Edge runtime.
// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { verifyToken } from "@/lib/auth"
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value
const session = token ? await verifyToken(token) : null
if (!session) {
const url = new URL("/login", request.url)
url.searchParams.set("from", request.nextUrl.pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
}
Client Component: Login Form
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
export function LoginForm() {
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setPending(true)
setError(null)
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
})
setPending(false)
if (res.ok) {
router.push("/dashboard")
} else {
setError("Invalid email or password")
}
}
return (
<form onSubmit={onSubmit} className="space-y-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button disabled={pending}>{pending ? "Signing in…" : "Sign in"}</button>
{error && <p role="alert">{error}</p>}
</form>
)
}
Auth Strategies
| Strategy | Storage | Best For |
|---|---|---|
| JWT | httpOnly cookie | SPAs, APIs |
| Session | httpOnly cookie | Server-rendered apps |
| OAuth | Provider-managed | Social login |
| API Keys | Header | Service-to-service |
Storing a token in localStorage is convenient but readable by any script —
one XSS bug leaks the session. An httpOnly cookie can't be read by
JavaScript, so prefer it for browser auth. Reserve Bearer tokens for
non-browser API clients.
Full Flow Cheat Sheet
Run these in order with a single cookie jar to exercise the whole lifecycle:
# 1. Sign up
curl -X POST http://localhost:4000/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"dev@example.com","password":"supersecret"}'
# 2. Log in — save the session cookie
curl -X POST http://localhost:4000/api/auth/login \
-H "Content-Type: application/json" -c cookies.txt \
-d '{"email":"dev@example.com","password":"supersecret"}'
# 3. Access a protected route with the cookie
curl http://localhost:4000/api/auth/profile -b cookies.txt
# 4. Log out — clears the cookie
curl -X POST http://localhost:4000/api/auth/logout -b cookies.txt
See It In Action
1// app/api/auth/login/route.ts2import bcrypt from "bcryptjs"3import { cookies } from "next/headers"4import { signToken } from "@/lib/auth"5 6export async function POST(request: Request) {7const { email, password } = await request.json()8const user = users.find((u) => u.email === email)9 10// Verify password — return 401 if mismatch or user not found11const valid = user && (await bcrypt.compare(password ?? "", user.passwordHash))12if (!valid) {13return Response.json({ message: "Invalid credentials" }, { status: 401 })14}15 16// Sign JWT and store in httpOnly cookie17const token = await signToken({ userId: user.id, email: user.email })18const cookieStore = await cookies()19cookieStore.set("session", token, {20httpOnly: true,21secure: process.env.NODE*ENV === "production",22sameSite: "lax",23path: "/",24maxAge: 60 * 60 _ 24 * 7, // 7 days25})26 27// Return user info (never expose the password hash)28return Response.json({ id: user.id, email: user.email })29}Verifies the password with bcrypt.compare, signs a JWT, stores it in an httpOnly cookie, and returns the user's id and email.