Authentication Implementation Guide¶
Complete guide to implementing authentication in Next.js applications using NextAuth.js (Auth.js v5).
Overview¶
This tutorial covers: - NextAuth.js v5 setup with App Router - OAuth providers (Google, GitHub) - Credentials provider (custom login) - Session management - Protected routes - Integration with FastAPI backend
Why NextAuth.js?¶
| Feature | Benefit |
|---|---|
| App Router Support | Full RSC and Server Actions integration |
| Multiple Providers | OAuth, Email, Credentials |
| Session Management | Automatic JWT or database sessions |
| Type-Safe | Full TypeScript support |
| Edge Compatible | Works with Edge Runtime |
Installation¶
Basic Setup¶
1. Environment Variables¶
.env.local:
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
# OAuth Providers
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"
Generate secret:
Configuration¶
2. Auth Route Handler¶
app/api/auth/[...nextauth]/route.ts:
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
import CredentialsProvider from 'next-auth/providers/credentials';
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// Call your backend API
const res = await fetch(`${process.env.API_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify({
email: credentials?.email,
password: credentials?.password,
}),
headers: { 'Content-Type': 'application/json' },
});
const user = await res.json();
if (res.ok && user) {
return user;
}
return null;
},
}),
],
callbacks: {
async jwt({ token, user, account }) {
// Persist the OAuth access_token and user ID to the token
if (account) {
token.accessToken = account.access_token;
}
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
// Send properties to the client
session.user.id = token.id as string;
session.accessToken = token.accessToken as string;
return session;
},
},
pages: {
signIn: '/auth/signin',
signOut: '/auth/signout',
error: '/auth/error',
},
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
});
export { handler as GET, handler as POST };
3. Type Definitions¶
types/next-auth.d.ts:
import 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
accessToken?: string;
}
interface User {
id: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
accessToken?: string;
}
}
Usage in Server Components¶
Get Session¶
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
export default async function ProtectedPage() {
const session = await getServerSession();
if (!session) {
redirect('/auth/signin');
}
return (
<div>
<h1>Welcome, {session.user?.name}!</h1>
<p>Email: {session.user?.email}</p>
</div>
);
}
Protected Route Pattern¶
app/dashboard/page.tsx:
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
async function getData(userId: string) {
const res = await fetch(`https://api.example.com/user/${userId}`);
return res.json();
}
export default async function DashboardPage() {
const session = await getServerSession();
if (!session?.user) {
redirect('/auth/signin');
}
const data = await getData(session.user.id);
return (
<div>
<h1>Dashboard</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
Usage in Client Components¶
Session Provider Setup¶
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>
);
}
Using useSession Hook¶
components/UserMenu.tsx:
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';
export function UserMenu() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <div>Loading...</div>;
}
if (!session) {
return (
<button onClick={() => signIn()}>
Sign In
</button>
);
}
return (
<div>
<span>Hello, {session.user?.name}</span>
<button onClick={() => signOut()}>
Sign Out
</button>
</div>
);
}
Custom Sign-In Page¶
app/auth/signin/page.tsx:
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid credentials');
} else {
router.push('/dashboard');
}
};
return (
<div className="max-w-md mx-auto mt-16 p-8">
<h1 className="text-3xl font-bold mb-8">Sign In</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border px-4 py-2 rounded"
required
/>
</div>
<div>
<label className="block mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border px-4 py-2 rounded"
required
/>
</div>
{error && (
<div className="bg-red-100 text-red-800 p-3 rounded">
{error}
</div>
)}
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
Sign In
</button>
</form>
<div className="mt-8">
<p className="text-center text-gray-600 mb-4">Or continue with</p>
<div className="space-y-2">
<button
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
className="w-full border border-gray-300 py-2 rounded hover:bg-gray-50"
>
Sign in with Google
</button>
<button
onClick={() => signIn('github', { callbackUrl: '/dashboard' })}
className="w-full border border-gray-300 py-2 rounded hover:bg-gray-50"
>
Sign in with GitHub
</button>
</div>
</div>
</div>
);
}
Server Actions with Authentication¶
lib/actions.ts:
'use server';
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const session = await getServerSession();
if (!session?.user) {
redirect('/auth/signin');
}
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Call your API with the user's session
const res = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.accessToken}`,
},
body: JSON.stringify({
title,
content,
userId: session.user.id,
}),
});
if (!res.ok) {
throw new Error('Failed to create post');
}
revalidatePath('/posts');
return { success: true };
}
export async function deletePost(postId: string) {
const session = await getServerSession();
if (!session?.user) {
redirect('/auth/signin');
}
// Verify ownership or admin role
const post = await fetch(`https://api.example.com/posts/${postId}`).then(r => r.json());
if (post.userId !== session.user.id) {
throw new Error('Unauthorized');
}
await fetch(`https://api.example.com/posts/${postId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${session.accessToken}`,
},
});
revalidatePath('/posts');
}
Middleware for Route Protection¶
middleware.ts:
export { default } from 'next-auth/middleware';
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/profile/:path*'],
};
Or with custom logic:
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const isAuth = !!token;
const isAuthPage = req.nextUrl.pathname.startsWith('/auth');
if (isAuthPage) {
if (isAuth) {
return NextResponse.redirect(new URL('/dashboard', req.url));
}
return null;
}
if (!isAuth) {
let from = req.nextUrl.pathname;
if (req.nextUrl.search) {
from += req.nextUrl.search;
}
return NextResponse.redirect(
new URL(`/auth/signin?from=${encodeURIComponent(from)}`, req.url)
);
}
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = {
matcher: ['/dashboard/:path*', '/profile/:path*'],
};
Integration with FastAPI Backend¶
NextAuth Configuration for FastAPI¶
app/api/auth/[...nextauth]/route.ts:
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
const handler = NextAuth({
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
try {
// Call FastAPI backend
const res = await fetch('https://corporate-api.px-wing.com/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: credentials?.email,
password: credentials?.password,
}),
});
if (!res.ok) {
return null;
}
const data = await res.json();
// Return user object with JWT token from FastAPI
return {
id: data.user.id,
email: data.user.email,
name: data.user.name,
accessToken: data.access_token,
};
} catch (error) {
console.error('Auth error:', error);
return null;
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
// Store FastAPI JWT in NextAuth session
if (user) {
token.id = user.id;
token.accessToken = user.accessToken;
}
return token;
},
async session({ session, token }) {
session.user.id = token.id as string;
session.accessToken = token.accessToken as string;
return session;
},
},
session: {
strategy: 'jwt',
},
});
export { handler as GET, handler as POST };
FastAPI JWT Verification¶
Python FastAPI side:
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from pydantic import BaseModel
app = FastAPI()
security = HTTPBearer()
SECRET_KEY = "your-nextauth-secret"
ALGORITHM = "HS256"
class User(BaseModel):
id: int
email: str
name: str
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> User:
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
return User(id=user_id, email=payload.get("email"), name=payload.get("name"))
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
@app.get("/api/protected")
async def protected_route(user: User = Depends(verify_token)):
return {"message": f"Hello {user.name}"}
Database Integration with Prisma¶
Setup Prisma Adapter¶
prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
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)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
Update NextAuth config:
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
const handler = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
// ... your providers
],
session: {
strategy: 'database', // Use database sessions
},
});
Role-Based Access Control¶
types/next-auth.d.ts:
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: 'admin' | 'user';
email?: string | null;
name?: string | null;
};
}
interface User {
role: 'admin' | 'user';
}
}
Protect admin routes:
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
export default async function AdminPage() {
const session = await getServerSession();
if (!session?.user || session.user.role !== 'admin') {
redirect('/unauthorized');
}
return <div>Admin Content</div>;
}
Testing Authentication¶
import { render, screen } from '@testing-library/react';
import { SessionProvider } from 'next-auth/react';
const mockSession = {
user: {
id: '1',
name: 'Test User',
email: 'test@example.com',
},
expires: '2024-12-31',
};
test('shows user menu when authenticated', () => {
render(
<SessionProvider session={mockSession}>
<UserMenu />
</SessionProvider>
);
expect(screen.getByText('Test User')).toBeInTheDocument();
});
Security Best Practices¶
1. Secure Environment Variables¶
# Use strong secrets
NEXTAUTH_SECRET=$(openssl rand -base64 32)
# Never commit .env files
echo ".env*.local" >> .gitignore
2. HTTPS in Production¶
// next-auth.config.ts
export const config = {
useSecureCookies: process.env.NODE_ENV === 'production',
};
3. CSRF Protection¶
NextAuth.js automatically handles CSRF protection.
4. Rate Limiting¶
// middleware.ts
import { Ratelimit } from '@upstash/ratelimit';
const ratelimit = new Ratelimit({
redis: redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
export async function middleware(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response('Too many requests', { status: 429 });
}
}
Common Patterns¶
Redirect After Login¶
Conditional Rendering¶
'use client';
import { useSession } from 'next-auth/react';
export function ConditionalContent() {
const { data: session } = useSession();
if (!session) {
return <PublicContent />;
}
return <PrivateContent user={session.user} />;
}