Skip to main content
Advertisement

13.3 Authentication — NextAuth.js (Auth.js), OAuth, JWT Sessions, Middleware Protection

What is Authentication?

In web applications, authentication is the process of verifying "who is this user?", while authorization determines "what can this user do?".

There are several ways to implement authentication in a Next.js app, but the most widely used official solution is Auth.js (formerly NextAuth.js). Auth.js supports a wide range of authentication methods in a unified way, including OAuth providers (Google, GitHub, Kakao, etc.), email/password, and magic links.

Authentication Flow Overview

User → Login Page → OAuth Provider (Google, etc.) → Callback URL → Session Issued → Access Protected Page
ConceptDescription
SessionA store of user information that maintains login state
JWTJSON Web Token — validates sessions on the client side without a server
OAuthA standard protocol that delegates authentication to a third-party service (Google, GitHub, etc.)
MiddlewareA layer that checks authentication before a request reaches a page

Installing and Configuring Auth.js

Installation

npm install next-auth@beta

Auth.js v5 (beta) is fully compatible with Next.js 15 App Router.

Environment Variables

# .env.local
AUTH_SECRET="your-random-secret-at-least-32-chars" # openssl rand -base64 32
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"

auth.ts — Core Configuration File

// auth.ts (project root)
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google,
GitHub,
],
callbacks: {
// Store additional information in JWT
jwt({ token, user }) {
if (user) {
token.role = user.role ?? 'user';
}
return token;
},
// Expose JWT information in the session
session({ session, token }) {
session.user.role = token.role as string;
return session;
},
},
});

Registering the Route Handler

// app/api/auth/[...nextauth]/route.ts
export { handlers as GET, handlers as POST } from '@/auth';

Auth.js automatically handles routes like /api/auth/signin, /api/auth/signout, /api/auth/callback/*, etc.


Connecting OAuth Providers

Google OAuth Setup

  1. Create a project in the Google Cloud Console
  2. Issue an OAuth 2.0 Client ID
  3. Add authorized redirect URIs:
    • Development: http://localhost:3000/api/auth/callback/google
    • Production: https://yourdomain.com/api/auth/callback/google

GitHub OAuth Setup

  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
  2. Authorization callback URL: http://localhost:3000/api/auth/callback/github

Custom Login Page

// app/login/page.tsx
import { signIn } from '@/auth';

export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
<h1 className="mb-6 text-center text-2xl font-bold text-gray-900">
Sign In
</h1>

{/* Google Login */}
<form
action={async () => {
'use server';
await signIn('google', { redirectTo: '/dashboard' });
}}
>
<button
type="submit"
className="mb-3 flex w-full items-center justify-center gap-3 rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
<svg className="h-5 w-5" viewBox="0 0 24 24">
{/* Google icon SVG path omitted */}
</svg>
Continue with Google
</button>
</form>

{/* GitHub Login */}
<form
action={async () => {
'use server';
await signIn('github', { redirectTo: '/dashboard' });
}}
>
<button
type="submit"
className="flex w-full items-center justify-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm font-medium text-white hover:bg-gray-700"
>
Continue with GitHub
</button>
</form>
</div>
</div>
);
}

Register the custom page path in Auth.js configuration:

// auth.ts update
export const { handlers, signIn, signOut, auth } = NextAuth({
pages: {
signIn: '/login',
error: '/login', // Errors also redirect to login page
},
providers: [Google, GitHub],
// ...
});

JWT Session Management

Retrieving Session — Server Component

// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
const session = await auth();

if (!session?.user) {
redirect('/login');
}

return (
<div>
<h1>Hello, {session.user.name}!</h1>
<p>Email: {session.user.email}</p>
<p>Role: {session.user.role}</p>
<img
src={session.user.image ?? ''}
alt="Profile"
className="h-12 w-12 rounded-full"
/>
</div>
);
}

Retrieving Session — Client Component

// components/UserMenu.tsx
'use client';

import { useSession, signOut } from 'next-auth/react';

export default function UserMenu() {
const { data: session, status } = useSession();

if (status === 'loading') {
return <div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />;
}

if (!session) {
return <a href="/login">Sign In</a>;
}

return (
<div className="relative">
<img
src={session.user?.image ?? ''}
alt="Profile"
className="h-8 w-8 cursor-pointer rounded-full"
/>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="mt-2 rounded bg-red-500 px-3 py-1 text-sm text-white"
>
Sign Out
</button>
</div>
);
}

Wrapping with SessionProvider

To use useSession on the client, SessionProvider is required at the top level.

// app/providers.tsx
'use client';

import { SessionProvider } from 'next-auth/react';

export default 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>
);
}

Protecting Routes with Middleware

Middleware runs on the Edge Runtime and can verify authentication before a request reaches an actual page or API. This is the most efficient protection method.

Basic Middleware Configuration

// middleware.ts (project root)
export { auth as middleware } from '@/auth';

export const config = {
matcher: [
// Path patterns to protect
'/dashboard/:path*',
'/admin/:path*',
'/profile/:path*',
'/api/protected/:path*',
],
};

Role-Based Access Control (RBAC)

// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export default auth((req) => {
const { pathname } = req.nextUrl;
const session = req.auth;

// Handle unauthenticated users
if (!session) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}

// Protect admin-only pages
if (pathname.startsWith('/admin') && session.user?.role !== 'admin') {
return NextResponse.redirect(new URL('/403', req.url));
}

return NextResponse.next();
});

export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/profile/:path*'],
};

Practical Example — Building a Complete Authentication System

Project Structure

app/
(auth)/
login/
page.tsx ← Login page
(protected)/
layout.tsx ← Authentication-required layout
dashboard/
page.tsx ← Dashboard
admin/
page.tsx ← Admin only
api/
auth/
[...nextauth]/
route.ts ← Auth.js handler
auth.ts ← Auth.js configuration
middleware.ts ← Route protection

Authentication-Required Layout

// app/(protected)/layout.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import Navbar from '@/components/Navbar';

export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();

if (!session) {
redirect('/login');
}

return (
<div className="min-h-screen bg-gray-50">
<Navbar session={session} />
<main className="container mx-auto px-4 py-8">{children}</main>
</div>
);
}
// components/Navbar.tsx
import { signOut } from '@/auth';
import { Session } from 'next-auth';

export default function Navbar({ session }: { session: Session }) {
return (
<nav className="border-b bg-white px-4 py-3">
<div className="container mx-auto flex items-center justify-between">
<a href="/dashboard" className="text-xl font-bold text-blue-600">
MyApp
</a>

<div className="flex items-center gap-4">
{session.user?.role === 'admin' && (
<a
href="/admin"
className="text-sm font-medium text-purple-600 hover:text-purple-800"
>
Admin
</a>
)}

<div className="flex items-center gap-2">
{session.user?.image && (
<img
src={session.user.image}
alt="Profile"
className="h-8 w-8 rounded-full"
/>
)}
<span className="text-sm text-gray-700">{session.user?.name}</span>
</div>

<form
action={async () => {
'use server';
await signOut({ redirectTo: '/' });
}}
>
<button
type="submit"
className="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
>
Sign Out
</button>
</form>
</div>
</div>
</nav>
);
}

Admin Page

// app/(protected)/admin/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function AdminPage() {
const session = await auth();

// Double protection: middleware + server component
if (session?.user?.role !== 'admin') {
redirect('/403');
}

return (
<div>
<h1 className="mb-6 text-2xl font-bold">Admin Dashboard</h1>
<div className="grid grid-cols-3 gap-4">
<StatCard title="Total Users" value="1,234" />
<StatCard title="New Today" value="42" />
<StatCard title="Monthly Active" value="891" />
</div>
</div>
);
}

function StatCard({ title, value }: { title: string; value: string }) {
return (
<div className="rounded-xl bg-white p-6 shadow">
<p className="text-sm text-gray-500">{title}</p>
<p className="mt-1 text-3xl font-bold text-gray-900">{value}</p>
</div>
);
}

Protecting API Routes

// app/api/protected/data/route.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export async function GET() {
const session = await auth();

if (!session) {
return NextResponse.json(
{ error: 'Authentication required.' },
{ status: 401 }
);
}

if (session.user?.role !== 'admin') {
return NextResponse.json(
{ error: 'Insufficient permissions.' },
{ status: 403 }
);
}

// Return protected data
return NextResponse.json({ data: 'Admin-only data' });
}

Extending TypeScript Types

To add custom fields (role) to Auth.js's Session type, you need to augment the types.

// types/next-auth.d.ts
import { DefaultSession } from 'next-auth';

declare module 'next-auth' {
interface Session {
user: {
role: string;
} & DefaultSession['user'];
}

interface User {
role?: string;
}
}

declare module 'next-auth/jwt' {
interface JWT {
role?: string;
}
}

Expert Tips

1. Persisting Sessions with a Database Adapter

Instead of the default JWT session, storing sessions in a database allows you to forcefully invalidate sessions from the server.

npm install @auth/prisma-adapter @prisma/client prisma
// auth.ts
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import Google from 'next-auth/providers/google';

export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'database' }, // DB sessions instead of JWT
providers: [Google],
});

2. Credentials Provider (Email/Password)

import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';

export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;

const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});

if (!user || !user.password) return null;

const isValid = await bcrypt.compare(
credentials.password as string,
user.password
);

if (!isValid) return null;

return { id: user.id, email: user.email, name: user.name };
},
}),
],
});

3. Middleware Performance Optimization

Since middleware runs on every request, exclude paths for static files.

// middleware.ts
export const config = {
matcher: [
/*
* Apply to all requests except:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};

4. CSRF Protection

Auth.js v5 automatically handles CSRF tokens by default. Calling signIn/signOut through Server Actions is automatically protected.

5. Customizing Session Expiration

export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days (in seconds)
updateAge: 24 * 60 * 60, // Refresh every 24 hours
},
// ...
});

6. Collecting Logs with Event Hooks

export const { handlers, auth, signIn, signOut } = NextAuth({
events: {
async signIn({ user, account }) {
console.log(`[AUTH] Sign in: ${user.email} via ${account?.provider}`);
// Save login record to database
},
async signOut() {
console.log('[AUTH] Sign out');
},
},
// ...
});
Advertisement