Auth Session Pattern
A cookie-based session architecture that stores session data in your database, not in a JWT. Sessions are revokable, auditable, and never expose sensitive data to the client. Integrates with Next.js middleware for zero-latency auth checks on every route.
Install
curl -fsSL https://skills.vishalvoid.com/install auth-session-pattern | bashWhy not JWTs
JWTs are stateless — you cannot invalidate a specific token without a blocklist, which negates the statelessness benefit. A database-backed session is slightly slower (one extra DB read per request) but gives you: instant logout that actually works, per-session revocation (e.g. 'log out all devices'), full audit log of session activity, and no risk of leaking sensitive data in a decodable token.
Database schema
Two tables: `User` (your user record) and `Session` (one row per active login). The session stores a cryptographically secure token, the user it belongs to, and an expiry. The token in the cookie is just a lookup key — no data lives in the cookie itself.
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
createdAt DateTime @default(now())
sessions Session[]
}
model Session {
id String @id @default(cuid())
token String @unique // stored in the cookie
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
userAgent String? // for audit display
ipAddress String?
@@index([token])
@@index([userId])
}// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
createdAt DateTime @default(now())
sessions Session[]
}
model Session {
id String @id @default(cuid())
token String @unique // stored in the cookie
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
userAgent String? // for audit display
ipAddress String?
@@index([token])
@@index([userId])
}Creating a session
On login, verify credentials, generate a secure random token, insert a session row, and set an `HttpOnly` cookie. The cookie carries only the token — nothing else.
// lib/auth/session.ts
import { db } from '@/lib/db'
import { cookies } from 'next/headers'
const SESSION_COOKIE = 'sid'
const SESSION_DURATION_DAYS = 30
export async function createSession(userId: string, meta?: { userAgent?: string; ipAddress?: string }) {
const token = crypto.randomUUID() + crypto.randomUUID() // 72-char opaque token
const session = await db.session.create({
data: {
token,
userId,
expiresAt: new Date(Date.now() + SESSION_DURATION_DAYS * 86_400_000),
userAgent: meta?.userAgent,
ipAddress: meta?.ipAddress,
},
})
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: session.expiresAt,
path: '/',
})
return session
}// lib/auth/session.ts
import { db } from '@/lib/db'
import { cookies } from 'next/headers'
const SESSION_COOKIE = 'sid'
const SESSION_DURATION_DAYS = 30
export async function createSession(userId: string, meta?: { userAgent?: string; ipAddress?: string }) {
const token = crypto.randomUUID() + crypto.randomUUID() // 72-char opaque token
const session = await db.session.create({
data: {
token,
userId,
expiresAt: new Date(Date.now() + SESSION_DURATION_DAYS * 86_400_000),
userAgent: meta?.userAgent,
ipAddress: meta?.ipAddress,
},
})
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: session.expiresAt,
path: '/',
})
return session
}Reading the session
The `auth()` function is called at the top of any server component, action, or route handler that needs the current user. It reads the cookie, looks up the session, and returns the user — or `null` if there's no valid session.
// lib/auth/index.ts
import { db } from '@/lib/db'
import { cookies } from 'next/headers'
import { cache } from 'react'
export type Session = {
user: { id: string; email: string; name: string | null }
sessionId: string
}
// cache() memoizes per-request — safe to call auth() multiple times
export const auth = cache(async (): Promise<Session | null> => {
const cookieStore = await cookies()
const token = cookieStore.get('sid')?.value
if (!token) return null
const session = await db.session.findUnique({
where: {
token,
expiresAt: { gt: new Date() }, // ignore expired sessions
},
select: {
id: true,
user: {
select: { id: true, email: true, name: true },
},
},
})
if (!session) return null
return { user: session.user, sessionId: session.id }
})// lib/auth/index.ts
import { db } from '@/lib/db'
import { cookies } from 'next/headers'
import { cache } from 'react'
export type Session = {
user: { id: string; email: string; name: string | null }
sessionId: string
}
// cache() memoizes per-request — safe to call auth() multiple times
export const auth = cache(async (): Promise<Session | null> => {
const cookieStore = await cookies()
const token = cookieStore.get('sid')?.value
if (!token) return null
const session = await db.session.findUnique({
where: {
token,
expiresAt: { gt: new Date() }, // ignore expired sessions
},
select: {
id: true,
user: {
select: { id: true, email: true, name: true },
},
},
})
if (!session) return null
return { user: session.user, sessionId: session.id }
})Middleware for route protection
Next.js middleware runs on the edge before the page renders. Read the session token from the cookie and redirect unauthenticated requests to `/login`. Because this runs before the page component, there's no flash of unauthorized content.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const PUBLIC_PATHS = ['/', '/login', '/register', '/api/auth']
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
if (isPublic) return NextResponse.next()
const token = req.cookies.get('sid')?.value
if (!token) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const PUBLIC_PATHS = ['/', '/login', '/register', '/api/auth']
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
if (isPublic) return NextResponse.next()
const token = req.cookies.get('sid')?.value
if (!token) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Session revocation
Logout deletes the session row from the database — the token is immediately invalid everywhere, including other devices. For 'log out all devices', delete every session where `userId = currentUser.id`.
// lib/auth/session.ts (continued)
export async function deleteSession(token: string) {
await db.session.delete({ where: { token } }).catch(() => null)
const cookieStore = await cookies()
cookieStore.delete('sid')
}
export async function deleteAllSessions(userId: string) {
await db.session.deleteMany({ where: { userId } })
const cookieStore = await cookies()
cookieStore.delete('sid')
}// lib/auth/session.ts (continued)
export async function deleteSession(token: string) {
await db.session.delete({ where: { token } }).catch(() => null)
const cookieStore = await cookies()
cookieStore.delete('sid')
}
export async function deleteAllSessions(userId: string) {
await db.session.deleteMany({ where: { userId } })
const cookieStore = await cookies()
cookieStore.delete('sid')
}Related skills