How to Add Secure Authentication to Next.js
Using NextAuth.js (Auth.js) for App Router
TL;DR
TL;DR
NextAuth.js handles the auth mechanics for you. The mistakes happen after: skipping NEXTAUTH_SECRET in production (sessions won't be secure without it), guarding routes with middleware but forgetting to verify sessions in API routes, and confusing authentication with authorization. Knowing who someone is doesn't mean they can access whatever they're requesting.
Prerequisites
You'll need a Next.js 13+ app with App Router. This guide uses NextAuth.js v4 (also known as Auth.js).
Step-by-Step Guide
Create the auth configuration
Create app/api/auth/[...nextauth]/route.ts:
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { NextAuthOptions } from 'next-auth';
export const authOptions: NextAuthOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, token }) {
// Add user ID to session
if (session.user) {
session.user.id = token.sub!;
}
return session;
},
},
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Set up environment variables
Add to .env.local:
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=your-super-secret-key-here
NEXTAUTH_URL=http://localhost:3000
# OAuth provider credentials
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
Critical: NEXTAUTH_SECRET must be set in production. Without it, your sessions are not secure.
Protect routes with middleware
Create middleware.ts in your project root:
import { withAuth } from 'next-auth/middleware';
export default withAuth({
callbacks: {
authorized: ({ token }) => !!token,
},
});
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/api/protected/:path*'],
};
Get session in Server Components
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/api/auth/signin');
}
return (
<div>
<h1>Welcome, {session.user?.name}</h1>
</div>
);
}
Protect API routes
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Now check authorization (does this user own this resource?)
const userId = session.user.id;
// Fetch data scoped to this user
const data = await db.posts.findMany({ where: { authorId: userId } });
return NextResponse.json(data);
}
Add session provider for client components
Create app/providers.tsx:
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
Wrap your app in app/layout.tsx:
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Common Security Mistakes
- Missing NEXTAUTH_SECRET in production: sessions won't be secure without it, full stop
- Only checking auth on the frontend: API routes are equally exposed and need their own checks
- Forgetting authorization: auth proves who someone is, not what they're allowed to do
- Exposing session data: anything in the JWT reaches the client, so keep sensitive fields server-side
Testing Your Auth
- Access
/dashboardwithout logging in. It should redirect, not load. - Call an API route with no session. You should get a 401 back.
- Request another user's data by ID. It should be denied, not silently return empty.
- Open DevTools after sign-in and check what's in the JWT. Nothing sensitive should be there.