Use when adding new error messages to React, or seeing "unknown error code" warnings.
npx skills add gocallum/nextjs16-agent-skills --skill "authjs-skills"
Install specific skill from multi-skill repository
# Description
Auth.js v5 setup for Next.js authentication including Google OAuth, credentials provider, environment configuration, and core API integration
# SKILL.md
name: authjs-skills
description: Auth.js v5 setup for Next.js authentication including Google OAuth, credentials provider, environment configuration, and core API integration
Links
- Getting Started: https://authjs.dev/getting-started/installation?framework=Next.js
- Migrating to v5: https://authjs.dev/getting-started/migrating-to-v5
- Google Provider: https://authjs.dev/getting-started/providers/google
- Credentials Provider: https://authjs.dev/getting-started/providers/credentials
- Core API Reference: https://authjs.dev/reference/core
- Session Management: https://authjs.dev/getting-started/session-management
- Concepts: https://authjs.dev/concepts
Installation
pnpm add next-auth@beta
Note: Auth.js v5 is currently in beta. Use next-auth@beta to install the latest v5 version.
What's New in Auth.js v5?
Key Changes from v4
- Simplified Configuration: More streamlined setup with better TypeScript support
- Universal
auth()Export: Single function for authentication across all contexts - Enhanced Security: Improved CSRF protection and session handling
- Edge Runtime Support: Full compatibility with Edge Runtime and middleware
- Better Type Safety: Improved TypeScript definitions throughout
Environment Variables
Required Environment Variables
# Auth.js Configuration
AUTH_SECRET=your_secret_key_here
# Google OAuth (if using Google provider)
AUTH_GOOGLE_ID=your_google_client_id
AUTH_GOOGLE_SECRET=your_google_client_secret
# For production deployments
AUTH_URL=https://yourdomain.com
# For development (optional, defaults to http://localhost:3000)
# AUTH_URL=http://localhost:3000
Generating AUTH_SECRET
# Generate a random secret (Unix/Linux/macOS)
openssl rand -base64 32
# Alternative using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Using pnpm
pnpm dlx auth secret
Important: Never commit AUTH_SECRET to version control. Use .env.local for development.
Basic Setup (Next.js App Router)
1. Create auth.ts Configuration File
Create auth.ts at the project root (next to package.json):
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
// TODO: Implement your authentication logic here
// This is a basic example - see Credentials Provider section below for complete implementation
if (!credentials?.email || !credentials?.password) {
return null
}
// Example: validate against database (placeholder)
// See "Credentials Provider" section for full implementation with bcrypt
const user = { id: "1", email: credentials.email, name: "User" } // Replace with actual DB lookup
if (!user) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
}
},
}),
],
pages: {
signIn: '/auth/signin',
},
callbacks: {
authorized: async ({ auth }) => {
// Return true if user is authenticated
return !!auth
},
},
})
Note: This is a basic setup example. For production-ready credentials authentication, see the "Credentials Provider" section below which includes proper password hashing with bcrypt and database integration.
2. Create API Route Handler
Create app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth"
export const { GET, POST } = handlers
3. Add Middleware (Optional but Recommended)
Create middleware.ts at the project root:
export { auth as middleware } from "@/auth"
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
For more control:
import { auth } from "@/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
if (isOnDashboard && !isLoggedIn) {
return Response.redirect(new URL('/auth/signin', req.url))
}
})
export const config = {
matcher: ['/dashboard/:path*', '/profile/:path*'],
}
Google OAuth Provider
1. Google Cloud Console Setup
- Go to Google Cloud Console
- Create a new project or select existing
- Enable Google+ API
- Create OAuth 2.0 credentials:
- Application type: Web application
- Authorized redirect URIs:
- Development:
http://localhost:3000/api/auth/callback/google - Production:
https://yourdomain.com/api/auth/callback/google
- Development:
- Copy Client ID and Client Secret to
.env.local
2. Configuration
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code"
}
}
}),
],
})
3. Google Provider Options
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
// Request additional scopes
authorization: {
params: {
scope: "openid email profile",
prompt: "select_account", // Force account selection
}
},
// Allow specific domains only
allowDangerousEmailAccountLinking: false,
})
Credentials Provider (Username/Password)
Required Dependencies
# Install required packages for credentials provider
pnpm add bcryptjs zod
pnpm add -D @types/bcryptjs
1. Basic Configuration
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { z } from "zod"
import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma"
const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email", placeholder: "[email protected]" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
try {
const { email, password } = credentialsSchema.parse(credentials)
// Fetch user from database
const user = await prisma.user.findUnique({
where: { email },
})
if (!user) {
throw new Error("User not found")
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.hashedPassword)
if (!isValidPassword) {
throw new Error("Invalid password")
}
// Return user object (must include id)
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
}
} catch (error) {
console.error("Authentication error:", error)
return null
}
},
}),
],
session: {
strategy: "jwt", // Required for credentials provider
},
})
2. User Registration Example
// app/api/auth/register/route.ts
import { NextResponse } from "next/server"
import bcrypt from "bcryptjs"
import { z } from "zod"
import { prisma } from "@/lib/prisma"
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2),
})
export async function POST(req: Request) {
try {
const body = await req.json()
const { email, password, name } = registerSchema.parse(body)
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email },
})
if (existingUser) {
return NextResponse.json(
{ error: "User already exists" },
{ status: 400 }
)
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10)
// Create user
const user = await prisma.user.create({
data: {
email,
name,
hashedPassword,
},
})
return NextResponse.json(
{ message: "User created successfully", userId: user.id },
{ status: 201 }
)
} catch (error) {
console.error("Registration error:", error)
return NextResponse.json(
{ error: "Failed to register user" },
{ status: 500 }
)
}
}
Using Auth in Components
Server Components
import { auth } from "@/auth"
export default async function ProfilePage() {
const session = await auth()
if (!session?.user) {
return <div>Not authenticated</div>
}
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<p>Email: {session.user.email}</p>
</div>
)
}
Server Actions
"use server"
import { auth } from "@/auth"
import { revalidatePath } from "next/cache"
import { prisma } from "@/lib/prisma"
export async function updateProfile(formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error("Not authenticated")
}
const name = formData.get("name") as string
// Update database
await prisma.user.update({
where: { id: session.user.id },
data: { name },
})
revalidatePath("/profile")
}
Client Components (with SessionProvider)
// 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,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
// app/components/user-profile.tsx
"use client"
import { useSession, signIn, signOut } from "next-auth/react"
export function UserProfile() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div>Loading...</div>
}
if (!session) {
return (
<button onClick={() => signIn()}>
Sign In
</button>
)
}
return (
<div>
<p>Signed in as {session.user?.email}</p>
<button onClick={() => signOut()}>
Sign Out
</button>
</div>
)
}
Sign In/Out Actions
Programmatic Sign In
import { signIn } from "@/auth"
// Server Action
export async function handleSignIn(provider: string) {
"use server"
await signIn(provider)
}
// With credentials
export async function handleCredentialsSignIn(formData: FormData) {
"use server"
await signIn("credentials", formData)
}
// With redirect
export async function handleGoogleSignIn() {
"use server"
await signIn("google", { redirectTo: "/dashboard" })
}
Sign In Form Component
// app/auth/signin/page.tsx
import { signIn } from "@/auth"
export default function SignInPage() {
return (
<div>
<h1>Sign In</h1>
{/* Google OAuth */}
<form
action={async () => {
"use server"
await signIn("google")
}}
>
<button type="submit">Sign in with Google</button>
</form>
{/* Credentials */}
<form
action={async (formData) => {
"use server"
await signIn("credentials", formData)
}}
>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button type="submit">Sign In</button>
</form>
</div>
)
}
Sign Out
import { signOut } from "@/auth"
export default function SignOutButton() {
return (
<form
action={async () => {
"use server"
await signOut()
}}
>
<button type="submit">Sign Out</button>
</form>
)
}
Session Management
Session Strategy
Auth.js v5 supports two session strategies:
- JWT (Default): Stores session in encrypted JWT token
- Database: Stores session in database
export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt", // or "database"
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // 24 hours
},
})
Extending the Session
import NextAuth from "next-auth"
import type { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
role: string
} & DefaultSession["user"]
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = user.role
}
return token
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
},
},
})
Callbacks
Essential Callbacks
export const { handlers, signIn, signOut, auth } = NextAuth({
callbacks: {
// Called when user signs in
async signIn({ user, account, profile }) {
// Return true to allow sign in, false to deny
// Example: Check if email is verified
if (account?.provider === "google") {
return profile?.email_verified === true
}
return true
},
// Called whenever a JWT is created or updated
async jwt({ token, user, account }) {
if (user) {
token.id = user.id
}
if (account) {
token.accessToken = account.access_token
}
return token
},
// Called whenever a session is checked
async session({ session, token }) {
session.user.id = token.id as string
session.accessToken = token.accessToken as string
return session
},
// Called on middleware and server-side auth checks
async authorized({ auth, request }) {
const isLoggedIn = !!auth?.user
const isOnDashboard = request.nextUrl.pathname.startsWith("/dashboard")
if (isOnDashboard) {
return isLoggedIn
}
return true
},
// Called when user is redirected
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`
// Allows callback URLs on the same origin
else if (new URL(url).origin === baseUrl) return url
return baseUrl
},
},
})
Database Adapter (Optional)
For persisting users, accounts, and sessions in a database, install the Prisma adapter:
pnpm add @auth/prisma-adapter
Then configure it in your auth.ts:
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: {
strategy: "database",
},
providers: [
// ... providers
],
})
Required Prisma schema:
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
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)
}
API Routes
Custom API Endpoints
// app/api/user/route.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
)
}
return NextResponse.json({
user: session.user,
})
}
Protected Route Helper
// lib/auth-helpers.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"
import type { Session } from "next-auth"
export async function withAuth(
handler: (session: Session) => Promise<NextResponse>
) {
const session = await auth()
if (!session?.user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
)
}
return handler(session)
}
// Usage
export async function GET() {
return withAuth(async (session) => {
return NextResponse.json({ userId: session.user.id })
})
}
Best Practices
Security
- Always hash passwords: Use bcrypt, argon2, or similar
- Use HTTPS in production: Required for secure cookie transmission
- Validate environment variables: Check AUTH_SECRET and provider credentials
- Set secure cookie options:
typescript cookies: { sessionToken: { name: `__Secure-next-auth.session-token`, options: { httpOnly: true, sameSite: 'lax', path: '/', secure: process.env.NODE_ENV === 'production', }, }, } - Implement rate limiting: Protect sign-in endpoints
- Use CSRF protection: Enabled by default in v5
- Validate redirects: Use the
redirectcallback to prevent open redirects
Session Management
- Use appropriate maxAge: Default 30 days, adjust based on security requirements
- Update sessions regularly: Use
updateAgeto refresh session data - Handle session expiry gracefully: Provide clear UI feedback
- Secure session storage: Use database strategy for sensitive applications
Provider Configuration
- Google OAuth: Request minimum required scopes
- Credentials: Always validate input with zod or similar
- Multiple providers: Allow account linking carefully
- Provider-specific logic: Use callbacks to handle provider differences
Performance
- Cache session checks: Use middleware for route protection
- Minimize database calls: Use JWT strategy when appropriate
- Optimize database queries: Add indexes on frequently queried fields
- Use Edge Runtime: For faster authentication checks in middleware
Type Safety
- Extend types properly: Use module augmentation for custom session fields
- Validate inputs: Use zod for runtime type checking
- TypeScript strict mode: Enable for better type safety
Common Patterns
Protected Pages with Middleware
import { auth } from "@/auth"
import { NextResponse } from "next/server"
export default auth((req) => {
const isLoggedIn = !!req.auth
const { pathname } = req.nextUrl
// Public routes
const publicRoutes = ['/auth/signin', '/auth/register', '/']
if (publicRoutes.includes(pathname)) {
return NextResponse.next()
}
// Protected routes
if (!isLoggedIn) {
const signInUrl = new URL('/auth/signin', req.url)
signInUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(signInUrl)
}
// Role-based access
const adminRoutes = ['/admin']
if (adminRoutes.some(route => pathname.startsWith(route))) {
if (req.auth.user.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', req.url))
}
}
return NextResponse.next()
})
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
Multi-Provider Setup
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "@/lib/prisma"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
Credentials({
// ... credentials config
}),
],
callbacks: {
async signIn({ user, account, profile }) {
// Link accounts with same email
if (account?.provider !== "credentials") {
const existingUser = await prisma.user.findUnique({
where: { email: user.email },
})
if (existingUser) {
// Link account to existing user
await prisma.account.create({
data: {
userId: existingUser.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
refresh_token: account.refresh_token,
},
})
}
}
return true
},
},
})
Custom Sign In Page
// app/auth/signin/page.tsx
import { signIn } from "@/auth"
import { redirect } from "next/navigation"
export default function SignInPage({
searchParams,
}: {
searchParams: { callbackUrl?: string }
}) {
const callbackUrl = searchParams.callbackUrl || "/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>
{/* OAuth Providers */}
<div className="space-y-4">
<form
action={async () => {
"use server"
await signIn("google", { redirectTo: callbackUrl })
}}
>
<button
type="submit"
className="w-full bg-white border border-gray-300 text-gray-700 py-2 px-4 rounded hover:bg-gray-50"
>
Continue with Google
</button>
</form>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">Or</span>
</div>
</div>
{/* Credentials Form */}
<form
action={async (formData) => {
"use server"
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: callbackUrl,
})
} catch (error) {
redirect(`/auth/signin?error=CredentialsSignin&callbackUrl=${callbackUrl}`)
}
}}
className="space-y-4"
>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
>
Sign In
</button>
</form>
</div>
</div>
)
}
Role-Based Access Control (RBAC)
// lib/auth-rbac.ts
import { auth } from "@/auth"
export type Role = "admin" | "user" | "guest"
export async function checkRole(allowedRoles: Role[]) {
const session = await auth()
if (!session?.user) {
return false
}
const userRole = session.user.role as Role
return allowedRoles.includes(userRole)
}
// Usage in Server Component
export default async function AdminPage() {
const hasAccess = await checkRole(["admin"])
if (!hasAccess) {
redirect("/unauthorized")
}
return <div>Admin Dashboard</div>
}
// Usage in Server Action
export async function deleteUser(userId: string) {
"use server"
const hasAccess = await checkRole(["admin"])
if (!hasAccess) {
throw new Error("Unauthorized")
}
const { prisma } = await import("@/lib/prisma")
await prisma.user.delete({ where: { id: userId } })
}
Migration from v4 to v5
Key Differences
- Import changes:
next-authpackage remains the same, but imports are simplified - Universal
auth(): ReplacegetServerSessionwithauth() - Middleware: Use
authas middleware directly - Configuration: More streamlined, fewer options needed
Migration Steps
// v4 (old)
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
export async function GET() {
const session = await getServerSession(authOptions)
}
// v5 (new)
import { auth } from "@/auth"
export async function GET() {
const session = await auth()
}
// v4 middleware (old)
import { withAuth } from "next-auth/middleware"
export default withAuth({
callbacks: {
authorized: ({ token }) => !!token,
},
})
// v5 middleware (new)
export { auth as middleware } from "@/auth"
Troubleshooting
Common Issues
AUTH_SECRET not set:
Error: AUTH_SECRET environment variable is not set
Generate and set AUTH_SECRET in .env.local
Google OAuth redirect mismatch:
Error: redirect_uri_mismatch
Ensure redirect URI in Google Console matches: http://localhost:3000/api/auth/callback/google
Session not persisting:
- Check AUTH_URL is set correctly
- Verify cookies are not blocked
- Ensure sessionToken cookie is being set (check browser DevTools)
TypeScript errors with session:
- Extend the Session and JWT types using module augmentation
- Run pnpm tsc --noEmit to check for type errors
Credentials provider not working:
- Ensure session.strategy is set to "jwt"
- Check authorize function returns correct user object with id field
- Verify password hashing/comparison logic
Resources
- Official Docs: https://authjs.dev
- GitHub: https://github.com/nextauthjs/next-auth
- Discord Community: https://discord.gg/nextauth
- Examples: https://github.com/nextauthjs/next-auth/tree/main/apps/examples
- Provider List: https://authjs.dev/getting-started/providers
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.