Authentication Patterns in Next.js in 2026: What Actually Works
Authentication is one of those things that sounds straightforward until you actually implement it. “Just add login” turns into a two-week rabbit hole of sessions, tokens, middleware, CSRF protection, OAuth flows, and edge cases that make you question your career choices.
I’ve built auth systems in Next.js three times over the past year, each with a different approach. Here’s what I’ve learned about what works, what doesn’t, and what’s changed in 2026.
The Landscape Right Now
Next.js auth in 2026 exists in a different world than it did two years ago. The App Router is now the standard. Server Components change how you think about auth state. Middleware runs at the edge. And the library ecosystem has consolidated around a few main options.
The major choices are:
NextAuth.js / Auth.js - the most established option. It’s been around since 2020 and has evolved significantly. The v5 release aligned it with the App Router and added better support for edge runtimes. It handles OAuth providers (Google, GitHub, etc.), email/password, and magic links. Configuration is reasonable, though it can get complex when you need custom session handling.
Clerk - a managed auth service that handles the entire flow: signup, login, user management, organisation management, MFA. You get pre-built UI components and a dashboard. It’s fast to implement but you’re dependent on their infrastructure and pricing.
Lucia - a lightweight, framework-agnostic auth library that gives you more control. It handles session management but leaves the UI and provider integration to you. It was my favourite for a while, though it went through a major rewrite recently.
Custom with jose + iron-session - rolling your own with JWT libraries and encrypted cookies. Maximum control, maximum responsibility.
What I’ve Settled On
For most projects, I’m now using Auth.js (NextAuth v5) with a database adapter. Here’s why.
The integration with Next.js middleware is solid. You can protect routes at the edge before they even reach your server components. The middleware pattern looks like this:
// middleware.ts
import { auth } from "@/auth"
export default auth((req) => {
if (!req.auth && req.nextUrl.pathname !== "/login") {
const newUrl = new URL("/login", req.nextUrl.origin)
return Response.redirect(newUrl)
}
})
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
}
This runs at the edge, which means unauthenticated requests to protected routes get redirected before any server-side rendering happens. It’s fast and it keeps your server components clean - by the time a server component renders, you know the user is authenticated.
For the session strategy, I use database sessions rather than JWTs. I know JWTs are stateless and theoretically more scalable, but database sessions give you instant revocation. If a user changes their password or you need to log someone out, you delete the session record and they’re out immediately. With JWTs, the token is valid until it expires, and implementing token revocation on top of JWTs basically means you’re building a session store anyway, which defeats the purpose.
The Server Component Pattern
In the App Router, checking auth in server components is straightforward with Auth.js:
// 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>Welcome, {session.user.name}</h1>
{/* Dashboard content */}
</div>
)
}
The auth() function reads the session cookie, validates it against the database, and returns the session object. No client-side JavaScript needed. No loading states. The page either renders with user data or redirects.
For client components that need session data, Auth.js provides a SessionProvider and useSession hook. I try to minimise this usage - most auth checks should happen on the server - but it’s there when you need it for things like showing the user’s name in a nav bar.
The Tricky Parts
OAuth callback handling. Setting up Google or GitHub OAuth sounds simple until you deal with the callback URL configuration, token refresh, and account linking (what happens when someone signs up with email and later tries to sign in with Google using the same email). Auth.js handles most of this, but the configuration requires attention.
Role-based access control (RBAC). Auth.js doesn’t have built-in RBAC. You need to extend the session with role information from your database. I add a role field to the session callback:
callbacks: {
async session({ session, user }) {
const dbUser = await db.user.findUnique({
where: { id: user.id },
select: { role: true }
})
session.user.role = dbUser?.role ?? "user"
return session
}
}
Then check the role in your middleware or server components. It works but it’s manual.
Password hashing. If you support email/password auth (and many apps still need to), you need to handle password hashing. Auth.js uses the Credentials provider for this, which is intentionally limited because password auth is hard to do safely. Use bcrypt or argon2 for hashing, enforce minimum password requirements, and implement rate limiting on login attempts.
CSRF protection. Auth.js handles this automatically for its own forms, but if you’re building custom login forms, you need to include the CSRF token. It’s a common source of “why isn’t login working” bugs.
When to Use Something Else
Auth.js isn’t always the right choice.
If you’re building a multi-tenant SaaS with organisation management, team invitations, and role hierarchies, consider Clerk or a dedicated auth service. Building all that yourself on top of Auth.js is doable but it’s a lot of work and a lot of security surface area.
If you need very fine-grained control over session handling - custom token formats, specific cookie configurations, unusual auth flows - Lucia or a custom solution gives you more flexibility. Auth.js’s opinions are generally good, but they’re still opinions, and sometimes they don’t match your requirements.
If your app is a simple internal tool with a handful of users, honestly, basic HTTP auth behind a reverse proxy might be all you need. Not everything requires a full auth system.
What I’d Tell Beginners
Start with Auth.js and a Google OAuth provider. Don’t start with email/password - OAuth is simpler and more secure for your first implementation. Get login and logout working. Get session access working in server components. Then add complexity as needed.
Don’t build your own auth from scratch unless you have a specific reason. The team at an AI consultancy I’ve worked with made an interesting observation: auth is one of those areas where developer confidence often exceeds developer competence. Everyone thinks they can build a secure auth system. Most people can’t, because the edge cases and attack vectors are non-obvious. Use established libraries that have been audited and battle-tested.
Read the OWASP authentication cheat sheet before implementing anything. It’ll take an hour and save you from the most common mistakes.
Auth isn’t glamorous. Nobody will praise your auth system. But when it’s done well, nobody will notice it, and that’s the goal.