Passport vs NextAuth: Express vs Next.js Auth (2026)
TL;DR
Passport for Express/Node.js backends; NextAuth for Next.js apps. Passport.js (~5M weekly downloads) is the authentication middleware standard for Express — battle-tested, 500+ strategies, and the go-to choice for custom backend auth flows. NextAuth/Auth.js (~2.5M downloads) was designed specifically for Next.js route handlers but now supports multiple frameworks via Auth.js v5. If you're building an Express REST API, Passport is the standard. If you're building a Next.js app, NextAuth is the obvious starting point.
Key Takeaways
- Passport.js: ~5M weekly downloads — NextAuth/Auth.js: ~2.5M (npm, March 2026)
- Passport has 500+ strategies — covering OAuth, LDAP, SAML, OpenID, JWT, and local login
- NextAuth is Next.js-first — server actions, App Router, and React Server Components support built in
- Auth.js v5 is multi-framework — NextAuth expanded beyond Next.js to support SvelteKit, SolidStart, and Nuxt
- Passport is framework-agnostic — works with Express, Fastify, Hapi, Koa, and any Connect-compatible middleware
- Session handling differs — Passport uses express-session; NextAuth manages its own JWT/database sessions
Passport.js: The Express Standard
Passport has been the authentication middleware for Express for over a decade. Its design is simple: strategies are pluggable middleware functions that verify credentials and call done(err, user). The framework doesn't care how you verify credentials — it just calls your strategy and sets req.user on success. This composable design is why the strategy ecosystem has grown to 500+ packages.
The core setup requires three things: configuring a strategy, adding serialize/deserialize functions for session support, and registering the passport middleware on your Express app. Once those are in place, protecting routes is a single passport.authenticate() call.
// Passport + Express — local (email/password) strategy
const express = require('express');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const session = require('express-session');
// Configure strategy — Passport calls this with credentials from the request
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
try {
const user = await User.findOne({ email });
if (!user) {
return done(null, false, { message: 'User not found' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return done(null, false, { message: 'Incorrect password' });
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// Serialize/deserialize — controls what gets stored in session
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (err) {
done(err);
}
});
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production' },
}));
app.use(passport.initialize());
app.use(passport.session());
// Login route
app.post('/auth/login', passport.authenticate('local'), (req, res) => {
res.json({ user: req.user });
});
// Protected route
app.get('/api/profile', ensureAuthenticated, (req, res) => {
res.json({ user: req.user });
});
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) return next();
res.status(401).json({ error: 'Authentication required' });
}
The verbosity here is intentional — Passport gives you explicit control over every step. You choose your session store, your serialization format, your error messages, and your middleware order.
OAuth with Passport: 500+ Strategies
Passport's strategy model shines for OAuth integrations. Each provider is an npm package following the same (accessToken, refreshToken, profile, done) callback convention. Once you know how one OAuth strategy works, you know how all of them work.
// Google OAuth2 with Passport
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
scope: ['email', 'profile'],
},
async (accessToken, refreshToken, profile, done) => {
// Find or create user in your database
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
avatar: profile.photos[0]?.value,
});
}
return done(null, user);
}
));
app.get('/auth/google', passport.authenticate('google'));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login?error=oauth_failed' }),
(req, res) => res.redirect('/dashboard')
);
Beyond common OAuth providers, Passport strategies exist for SAML (enterprise SSO), LDAP/Active Directory, OpenID Connect, Azure AD, Auth0, Okta, and dozens of specialized use cases. If you're building a B2B SaaS that needs SAML enterprise auth, passport-saml is mature and widely deployed. That specialization is harder to replicate with NextAuth.
JWT Authentication with Passport
Many Express APIs skip sessions entirely and use JWT. passport-jwt integrates cleanly:
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
try {
const user = await User.findById(payload.sub);
if (!user) return done(null, false);
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// Stateless auth — no session middleware needed
app.get('/api/data', passport.authenticate('jwt', { session: false }), (req, res) => {
res.json({ data: 'protected', user: req.user });
});
This pattern is common for Express APIs that serve mobile apps or SPAs where cookie-based sessions are impractical.
NextAuth (Auth.js) for Next.js App Router
NextAuth was purpose-built for Next.js. The v5 release (now branded Auth.js) aligns fully with the App Router paradigm — the auth handler exports route handlers directly, sessions are available server-side in Server Components and Server Actions, and the middleware integration is clean.
// auth.ts — core config (Next.js 14+ App Router)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/lib/db';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db), // persist sessions to your database
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async ({ email, password }) => {
const user = await validateCredentials(email as string, password as string);
return user ?? null;
},
}),
],
callbacks: {
session({ session, user }) {
session.user.id = user.id; // extend session with user ID
return session;
},
},
});
// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers;
Using the session in a Server Component is ergonomic — no useEffect, no loading state, no client-side auth check:
// app/dashboard/page.tsx — Server Component
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const session = await auth();
if (!session?.user) redirect('/login');
return (
<main>
<h1>Welcome, {session.user.name}</h1>
<p>Signed in as {session.user.email}</p>
</main>
);
}
// Protecting server actions
import { auth } from '@/auth';
export async function createPost(formData: FormData) {
'use server';
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
// session.user is available and typed
await db.post.create({
data: {
title: formData.get('title') as string,
authorId: session.user.id,
},
});
}
Auth.js v5: Multi-Framework Expansion
Auth.js v5 broke out of the Next.js-only box. The same session logic now works across modern meta-frameworks while keeping framework-specific integrations clean:
// SvelteKit (src/hooks.server.ts)
import { SvelteKitAuth } from '@auth/sveltekit';
import GitHub from '@auth/sveltekit/providers/github';
export const { handle } = SvelteKitAuth({
providers: [GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET })],
});
// Then in a SvelteKit load function:
// const session = await locals.auth();
// Nuxt (server/plugins/auth.ts)
import { NuxtAuth } from '@auth/nuxt';
import GitHub from '@auth/nuxt/providers/github';
export default NuxtAuth({
providers: [GitHub({})],
});
This multi-framework story means teams that use Auth.js for one project can reuse their mental model across their stack. The core provider configuration is identical across frameworks — only the adapter layer changes.
Session Management Differences
The session model for each library reflects their different use cases.
Passport uses express-session by default, which stores a session ID cookie client-side and keeps session data server-side (in memory, Redis, or a database store). This is the classic stateful session approach — suitable for traditional web apps but requires a session store that scales with your infrastructure.
NextAuth supports both database sessions (via adapters for Prisma, Drizzle, TypeORM, etc.) and JWT sessions. JWT sessions are stateless — the session payload is encrypted and stored in a cookie, so no database lookup is needed on each request. Database sessions are more feature-rich (you can invalidate them, list active sessions, etc.) but add latency on each authenticated request.
// NextAuth — choose session strategy
export const { handlers, auth } = NextAuth({
session: {
strategy: 'jwt', // stateless — no DB lookup per request
// strategy: 'database', // stateful — requires adapter
maxAge: 30 * 24 * 60 * 60, // 30 days
},
providers: [...],
});
Middleware Route Protection
A common pattern in Next.js is protecting groups of routes via middleware, so unauthenticated users are redirected before the page renders. NextAuth integrates cleanly with Next.js middleware through the auth() export:
// middleware.ts — protect all routes under /dashboard and /account
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const isAuthenticated = !!req.auth;
const isProtectedRoute = req.nextUrl.pathname.startsWith('/dashboard') ||
req.nextUrl.pathname.startsWith('/account');
if (isProtectedRoute && !isAuthenticated) {
const loginUrl = new URL('/login', req.nextUrl.origin);
loginUrl.searchParams.set('callbackUrl', req.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Passport achieves the same result via Express middleware applied to route groups, but since Express is server-only, the protection happens at the request handler level rather than at the edge. For Next.js apps that deploy on Vercel, the middleware runs at the edge globally — meaning the redirect happens before any serverless function is invoked, which is both faster for users and reduces unnecessary compute costs.
TypeScript Support
NextAuth has first-class TypeScript support. The auth() function returns a typed Session | null, and you can extend the session type by augmenting the module declarations:
// types/next-auth.d.ts
import type { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: 'admin' | 'user';
} & DefaultSession['user'];
}
}
Passport's TypeScript support requires @types/passport and manual type assertions. req.user is typed as Express.User, which you extend via declaration merging. It's workable but more boilerplate than NextAuth's approach.
Package Health
| Package | Weekly Downloads | Bundle Size (gzip) | Last Release | Maintained |
|---|---|---|---|---|
| passport | ~5M | ~5KB (core) | Active | Yes |
| next-auth | ~2.5M | ~45KB | Active (v5.x) | Yes — Balazs Orban |
Passport's small core bundle is misleading — you'll install multiple strategy packages separately. NextAuth's larger bundle includes built-in providers and session management that Passport offloads to external packages.
When to Choose
Choose Passport.js when:
- Building an Express REST API or GraphQL server
- You need specialized enterprise strategies (SAML, LDAP, Active Directory, Okta)
- Existing Express application where Passport is already established
- Backend-only auth with no server-rendered frontend
- You need full control over the auth flow, session format, and storage
- Building a multi-provider API that serves mobile apps or SPAs via JWT
Choose NextAuth/Auth.js when:
- Building a Next.js application — it is the standard choice for Next.js auth
- Using App Router and want session access in Server Components and Server Actions
- You want OAuth configured in minutes with built-in providers
- Using SvelteKit, Nuxt, or SolidStart (Auth.js v5 supports these)
- You want a managed session strategy (JWT or database) without rolling your own
- Team values convention over configuration for auth flows
See the full Passport vs NextAuth package comparison on PkgPulse for download trend charts and release history.
For database session storage with NextAuth, the Drizzle vs Kysely guide covers choosing the right TypeScript-native database layer. If you're evaluating validation for your API routes, see Zod vs TypeBox for the runtime vs schema-first tradeoff.
Browse the Passport package details and NextAuth package details on PkgPulse.
See the live comparison
View passport vs. nextauth on PkgPulse →