← Writing
Engineering·Mar 1, 2026·11 min read

Next.js Authentication: The Complete Guide (2026)

Auth is the most cargo-culted part of any SaaS. Everyone copies the tutorial, ships to production, and then gets burned six months later. Here's the definitive breakdown of every viable approach — Auth.js, Clerk, and custom JWT — with real production patterns and the mistakes you need to avoid.


TL;DR — The 3 Approaches

1.Auth.js (formerly NextAuth.js)Best for OAuth-only apps. Open source, App Router support, sessions via JWT or database. Use when you want control without reinventing the wheel.
2.ClerkBest for teams that want to ship fast. Prebuilt UI, MFA, organizations, passkeys. You pay in dollars, not engineering hours.
3.Roll-your-own JWTBest for enterprise or unusual requirements. Maximum control, maximum responsibility. Only do this if you know what you're getting into.

Authentication is the entry point to your entire product. Get it wrong and you're staring down session fixation bugs, token replay attacks, and a password reset flow that locks out real users. Get it right and it fades into the background — invisible infrastructure that just works.

The frustrating reality: most Next.js apps I audit have auth that was copied from a tutorial, never updated, and slowly accumulated security debt. The tutorial was fine in 2022. Your production system in 2026 is not a tutorial.

This guide covers the three viable auth approaches for Next.js, when to pick each one, and the specific production mistakes that will eventually burn you if you don't address them now.


The 3 Approaches: A Side-by-Side Comparison

Before diving into implementation, here's the honest trade-off matrix:

Auth.jsClerkCustom JWT
Setup time~2 hrs~30 min1–2 days
Monthly costFree$25–$100+Free
ControlHighMediumTotal
Prebuilt UINoYesNo
MFA / PasskeysManualBuilt-inBuild it
App Router supportv5 beta+FullFull
Best forOAuth SaaSFast shippingEnterprise

Auth.js (formerly NextAuth.js)

Auth.js is the go-to for Next.js OAuth flows. Version 5 (currently in beta but widely used in production) was rewritten specifically for the App Router. It supports Google, GitHub, Discord, and dozens of other providers out of the box.

Use Auth.js when: you need OAuth sign-in, you want database session storage, you want open-source with no vendor dependency, and you're comfortable with some configuration overhead.

Basic Setup with App Router

// src/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    session({ session, token }) {
      // Attach user ID to session for use in Server Components
      if (token.sub) session.user.id = token.sub;
      return session;
    },
  },
});

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

Reading the Session in a Server Component

// src/app/dashboard/page.tsx (Server Component)
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

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

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

  return (
    <main>
      <h1>Welcome, {session.user.name}</h1>
    </main>
  );
}
Production note: Auth.js v5 stores sessions as JWTs by default. If you need to invalidate sessions immediately (e.g., on account deletion or password change), you must use a database adapter so you can delete the session record server-side. JWTs cannot be invalidated before expiry without a blocklist.

Clerk

Clerk is the fastest way to add production-grade auth to a Next.js app. You get prebuilt sign-in/sign-up UI components, MFA, passkeys, organization management, and a full user dashboard — without writing a single auth handler.

Use Clerk when: you're shipping fast, your team's time is worth more than $25/month, and you want features like organizations, device management, and audit logs without building them yourself.

The key integration point in Next.js is middleware:

// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isPublicRoute = createRouteMatcher([
  '/',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/api/webhooks(.*)',
]);

export default clerkMiddleware((auth, req) => {
  if (!isPublicRoute(req)) {
    auth().protect();
  }
});

export const config = {
  matcher: ['/((?!_next|[^?]*\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', '/(api|trpc)(.*)'],
};

Clerk's auth() helper works in both Server Components and Route Handlers. The currentUser() function fetches the full user object when needed.

When Clerk is overkill: If your app is purely internal, enterprise-only with SAML requirements, or if you have strict data residency requirements, Clerk may not be the right fit. Its pricing also scales with monthly active users — model that cost before committing.

Roll Your Own JWT

Building your own auth is a serious commitment. You own the security model end to end — which means you own every vulnerability. That said, there are legitimate reasons to do it: you have unusual token requirements, you're integrating with an existing identity provider, or you're building for an enterprise client with specific compliance needs.

If you go this route, use the jose library. It's the Edge-compatible JWT implementation — no native Node.js crypto dependencies — which means it works in Next.js middleware.

// src/lib/auth/jwt.ts
import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

export async function signToken(payload: Record<string, unknown>) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('1h')
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch {
    return null;
  }
}

// Usage in a Route Handler
export async function POST(req: Request) {
  const { email, password } = await req.json();
  const user = await verifyCredentials(email, password);

  if (!user) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  const token = await signToken({ sub: user.id, email: user.email });

  const res = Response.json({ success: true });
  res.headers.set('Set-Cookie',
    `token=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600`
  );
  return res;
}
Security checklist for custom JWT: Always use HttpOnly cookies (never localStorage). Rotate your secret regularly and support key rotation. Implement refresh tokens with rotation — single long-lived tokens are a liability. Add a token family tracking mechanism to detect refresh token theft.

Protecting Routes with Middleware

Regardless of which auth solution you use, the correct place to enforce authentication in Next.js is middleware.ts. It runs at the Edge before any page renders, which means unauthenticated users never touch your Server Components or data fetching logic.

// middleware.ts — Auth.js + custom JWT approach
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth/jwt';

const PUBLIC_PATHS = ['/', '/login', '/signup', '/api/auth'];

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  const isPublic = PUBLIC_PATHS.some(p => pathname.startsWith(p));
  if (isPublic) return NextResponse.next();

  const token = req.cookies.get('token')?.value;
  const payload = token ? await verifyToken(token) : null;

  if (!payload) {
    const loginUrl = new URL('/login', req.url);
    loginUrl.searchParams.set('from', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};

The matcher config is critical. You must exclude static assets and Next.js internals — otherwise middleware runs on every image request, which will destroy your Edge function cold start performance.


The Auth Mistakes That Will Burn You

These are the production failures I see repeatedly across codebases. None of them show up in tutorials. All of them eventually cost real money or security incidents.

#1Not invalidating sessions on password change

When a user changes their password, every existing session — on every device — should be invalidated. JWT-based auth makes this hard by design. The fix: store a session version counter in your database and include it in the token. On password change, increment the counter. Middleware rejects tokens with an old version.

#2Storing sensitive data in the JWT payload

JWTs are base64-encoded, not encrypted. Anyone who gets the token (from logs, proxies, analytics tools) can read the payload. Store only the user ID and role in the JWT. Fetch everything else server-side from the database.

#3Missing refresh token rotation

If you issue long-lived access tokens without refresh tokens, a stolen token is valid until expiry. If you issue refresh tokens without rotation, a stolen refresh token is permanently valid. Implement one-time-use refresh tokens: each use invalidates the old token and issues a new one. If a refresh token is used twice, it's a sign of theft — invalidate the entire family.

#4No rate limiting on auth endpoints

Your /api/auth/login endpoint accepts unlimited password attempts by default. A basic brute-force attack will enumerate passwords against any account. Rate limit by IP and by email separately. Use exponential backoff, not a fixed window. Consider temporary lockout after N failures.


Which Should You Pick?

Here's the decision logic I use when evaluating auth for a new project:

Do you have < 2 days for auth setup and want prebuilt UI?
Use Clerk. Ship fast, pay the monthly fee, move on to product.
Do you need OAuth only (Google, GitHub, etc.) and want open source?
Use Auth.js v5. It handles the OAuth dance cleanly and has strong Next.js integration.
Do you have enterprise requirements, custom token claims, or existing identity infrastructure?
Roll your own with jose. Budget 2 days minimum, add refresh token rotation, add rate limiting from day one.
Do you need MFA, passkeys, organizations, or audit logs?
Clerk unless you have a strong reason otherwise. These features take weeks to build correctly.
Are you building for a heavily regulated industry (healthcare, finance)?
Custom JWT with a compliance-focused identity provider (Auth0, AWS Cognito, Azure AD B2C). Clerk and Auth.js may not have the compliance certifications you need.

The worst decision is indecision. Pick an approach, ship it, and spend your engineering time on actual product. Auth is not a differentiator. Your product is.


Conclusion

Authentication in Next.js has never been more approachable. Auth.js gives you solid OAuth without vendor lock-in. Clerk gives you a week of auth work in 30 minutes. Custom JWT gives you complete control when you genuinely need it.

The patterns that actually matter in production: always use HttpOnly cookies, not localStorage. Always invalidate sessions on password change. Always rate limit your auth endpoints. Always verify your token-handling logic can handle rotation correctly.

Get those four things right and the specific library you choose becomes a much less critical decision. The fundamentals are what kill production systems — not your choice of abstraction layer.

Abanoub Boctor
Abanoub Rodolf Boctor
Founder & CTO, ThynkQ · Mar 1, 2026
More articles →

Ready to build?

I turn ideas into shipped products. Fast.

Free 30-minute discovery call. Tell me what you're building — I'll tell you exactly how I'd approach it.

Book a free strategy call →

Related articles

7 Next.js App Router Mistakes That Kill Your SaaS PerformanceNext.js vs Remix for SaaS in 2026: An Engineer's Honest TakeStripe + Next.js: The Complete Integration Guide (2026)
← Next.js vs Remix for SaaSNext.js App Router Mistakes →