Skip to main content

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');
},
},
// ...
});