Authentication Skill
Patterns for implementing authentication in CAIO incubator projects using NextAuth.js.
Stack
- •NextAuth.js v5 (Auth.js) — Authentication framework
- •Prisma Adapter — Database session storage
- •Providers — Google, Email (magic link), or credentials
Setup
1. Install Dependencies
bash
bun add next-auth@beta @auth/prisma-adapter
2. Prisma Schema
prisma
// prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
// App-specific fields
trainingPlans TrainingPlan[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
3. Auth Configuration
typescript
// lib/auth.ts
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { db } from '@/lib/db'
import Google from 'next-auth/providers/google'
import Resend from 'next-auth/providers/resend'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Resend({
apiKey: process.env.RESEND_API_KEY,
from: 'noreply@trainingplan.ai',
}),
],
pages: {
signIn: '/login',
error: '/login',
},
callbacks: {
// Add user ID to session
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
// Control who can sign in
signIn: async ({ user, account, profile }) => {
// Add any sign-in restrictions here
return true
},
},
events: {
createUser: async ({ user }) => {
// Handle new user creation (e.g., send welcome email)
console.log('New user created:', user.email)
},
},
})
// Type augmentation for session
declare module 'next-auth' {
interface Session {
user: {
id: string
name?: string | null
email?: string | null
image?: string | null
}
}
}
4. API Route Handler
typescript
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers
5. Middleware
typescript
// middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isAuthPage = req.nextUrl.pathname.startsWith('/login')
const isProtectedPage = req.nextUrl.pathname.startsWith('/dashboard') ||
req.nextUrl.pathname.startsWith('/plans') ||
req.nextUrl.pathname.startsWith('/settings')
// Redirect logged-in users away from auth pages
if (isAuthPage && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', req.url))
}
// Redirect unauthenticated users to login
if (isProtectedPage && !isLoggedIn) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname)
return NextResponse.redirect(new URL(`/login?callbackUrl=${callbackUrl}`, req.url))
}
return NextResponse.next()
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Usage Patterns
Check Auth in Server Components
typescript
// app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
</div>
)
}
Check Auth in Server Actions
typescript
// actions/plans.ts
'use server'
import { auth } from '@/lib/auth'
export async function createPlan(formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// User is authenticated, proceed
const plan = await db.trainingPlan.create({
data: {
name: formData.get('name') as string,
userId: session.user.id,
},
})
return plan
}
Client-Side Auth State
typescript
// components/UserMenu.tsx
'use client'
import { useSession, signOut } from 'next-auth/react'
import { Button } from '@/components/ui/button'
export function UserMenu() {
const { data: session, status } = useSession()
if (status === 'loading') {
return <div>Loading...</div>
}
if (!session) {
return <Button href="/login">Sign In</Button>
}
return (
<div>
<span>{session.user.name}</span>
<Button onClick={() => signOut()}>Sign Out</Button>
</div>
)
}
Session Provider
typescript
// app/providers.tsx
'use client'
import { SessionProvider } from 'next-auth/react'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
</SessionProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Login Page
typescript
// app/login/page.tsx
import { auth, signIn } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export default async function LoginPage() {
const session = await auth()
if (session?.user) {
redirect('/dashboard')
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">Sign In</h1>
{/* Google Sign In */}
<form
action={async () => {
'use server'
await signIn('google', { redirectTo: '/dashboard' })
}}
>
<Button type="submit" className="w-full">
Continue with Google
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
{/* Email Sign In */}
<form
action={async (formData) => {
'use server'
await signIn('resend', {
email: formData.get('email'),
redirectTo: '/dashboard',
})
}}
>
<Input
name="email"
type="email"
placeholder="email@example.com"
required
/>
<Button type="submit" className="w-full mt-4">
Sign in with Email
</Button>
</form>
</div>
</div>
)
}
Authorization Helpers
typescript
// lib/auth-helpers.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
/**
* Get authenticated user or throw
*/
export async function requireAuth() {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
return session.user
}
/**
* Check if user owns a resource
*/
export async function requireOwnership(resourceUserId: string) {
const user = await requireAuth()
if (user.id !== resourceUserId) {
throw new Error('Forbidden')
}
return user
}
/**
* Get a plan with ownership check
*/
export async function getPlanWithAuth(planId: string) {
const user = await requireAuth()
const plan = await db.trainingPlan.findUnique({
where: { id: planId },
})
if (!plan) {
throw new Error('Not found')
}
if (plan.userId !== user.id) {
throw new Error('Forbidden')
}
return plan
}
Environment Variables
bash
# .env.local # NextAuth NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=your-secret-key-generate-with-openssl-rand-base64-32 # Google OAuth GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret # Resend (for magic links) RESEND_API_KEY=your-resend-api-key
Security Checklist
- • NEXTAUTH_SECRET is set and secure
- • OAuth redirect URIs are properly configured
- • CSRF protection is enabled (default in NextAuth)
- • Session cookies are secure in production
- • User data is validated before use
- • Authorization checks on all protected resources