uration (Auth.js v5 pattern)**
// src/identity/config.ts
import { createAuthConfig } from "next-auth";
import { githubProvider } from "next-auth/providers/github";
import { postgresAdapter } from "@auth/pg-adapter";
import { db } from "@/lib/database";
export const identityConfig = createAuthConfig({
providers: [githubProvider({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! })],
adapter: postgresAdapter(db),
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
callbacks: {
async session({ session, token }) {
session.user.id = token.sub as string;
return session;
},
},
});
Hosted Configuration (Clerk pattern)
// src/identity/config.ts
import { createClerkConfig } from "@clerk/nextjs/server";
export const identityConfig = createClerkConfig({
publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!,
secretKey: process.env.CLERK_SECRET_KEY!,
afterSignInUrl: "/dashboard",
afterSignUpUrl: "/onboarding",
});
Step 2: Expose Session Utilities and Route Handlers
Auth.js v5 eliminates the need for separate session imports. The factory returns both the HTTP handlers for the /api/auth/* routes and a server-side session getter.
// src/identity/session-manager.ts
import { identityConfig } from "./config";
export const { handlers: routeHandlers, auth: getSession } = identityConfig;
For hosted identity, session retrieval relies on the SDK's server utilities, which query Clerk's edge network.
// src/identity/session-manager.ts
import { auth as getClerkSession } from "@clerk/nextjs/server";
export const getSession = getClerkSession;
Step 3: Implement Route Protection with Explicit Verification
Middleware should handle UX redirects, but server actions and API routes must independently verify identity. This neutralizes edge bypass vulnerabilities and ensures consistent security posture.
// src/app/api/documents/route.ts
import { getSession } from "@/identity/session-manager";
import { NextResponse } from "next/server";
export async function GET() {
const session = await getSession();
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 });
}
// Proceed with authorized logic
return NextResponse.json({ message: "Access granted" });
}
Architecture Decisions & Rationale
- Centralized Config Factory: Decouples provider setup from routing logic. Makes it trivial to swap providers or add MFA later without touching route handlers.
- Explicit Server-Side Verification: Middleware runs at the edge and can be spoofed or bypassed. Re-verifying in Server Actions/Route Handlers ensures the session is cryptographically valid and matches your database state.
- Adapter-First Persistence (Auth.js): Storing sessions in your own database enables audit trails, session revocation, and compliance reporting. JWT-only strategies are faster but lack revocation capabilities without additional infrastructure.
- Declarative UI Guards (Clerk): Using
<Show when="signed-in"> reduces conditional rendering complexity and aligns with React Server Component streaming patterns.
Pitfall Guide
1. Middleware-Only Security Boundary
Explanation: Relying exclusively on proxy.ts or middleware.ts for route protection leaves your application vulnerable to header spoofing and edge cache inconsistencies.
Fix: Always re-verify sessions inside Server Actions, Route Handlers, and Server Components using getSession() or equivalent utilities. Treat middleware as a UX redirect layer, not an access control mechanism.
2. Ignoring Adapter Schema Requirements
Explanation: Auth.js v5 requires specific database tables (users, accounts, sessions, verificationTokens). Skipping schema migration or using mismatched column types causes silent session failures.
Fix: Run the official adapter migration scripts before first use. Verify column types match your database dialect (e.g., uuid vs varchar). Add indexes on session_token and user_id for query performance.
3. Assuming Hosted Middleware is Secure by Default
Explanation: Clerk's clerkMiddleware() explicitly opts routes into protection. Unmatched routes remain publicly accessible, which catches many teams off guard during initial deployment.
Fix: Define a strict matcher array that excludes static assets and API health checks. Explicitly whitelist protected paths using auth().protect() or declarative route groups. Audit your matcher regex regularly.
4. Underestimating Custom Auth UI Complexity
Explanation: Auth.js v5 ships zero UI components. Building password reset, email verification, MFA enrollment, and error state handling typically requires 2β3 weeks of dedicated frontend work.
Fix: If velocity matters, use a headless UI library or adopt a hosted solution. If you must build custom auth, implement a design system token strategy early to maintain consistency across forms, modals, and error banners.
5. Mixing Legacy v4 Patterns with v5
Explanation: Importing getServerSession() or using the old [...nextauth].ts catch-all route will cause runtime errors or silent fallbacks in v5.
Fix: Migrate entirely to the createAuthConfig() factory pattern. Remove all v4-specific imports. Update route handlers to destructure handlers from the new export. Run the official migration linter before deployment.
6. Neglecting Session Refresh Strategies
Explanation: JWT sessions expire silently. Without a refresh mechanism, users experience abrupt logouts during long-running workflows or background tab usage.
Fix: Implement sliding expiration via maxAge and updateAge callbacks. For database-backed sessions, schedule periodic cleanup jobs to purge expired tokens. Use React Query or SWR to silently re-fetch session state on window focus.
7. Overlooking Data Residency Compliance
Explanation: Hosted identity providers process user data across global edge networks. This violates GDPR, HIPAA, or enterprise data sovereignty requirements if not explicitly configured.
Fix: Verify provider data processing agreements. For regulated workloads, prefer self-managed identity with regional database deployment. Implement webhook-driven data sync if you must use hosted auth but require local data copies for compliance.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP / Startup Validation | Hosted Identity (Clerk) | Reduces setup to hours; includes org management and email delivery out-of-the-box. | Low initial cost; scales with MAU after 50k threshold. |
| Regulated Enterprise App | Self-Managed Identity (Auth.js v5) | Guarantees data residency, audit trails, and full schema control. | Higher engineering cost; predictable infrastructure pricing. |
| B2B SaaS with Teams | Hosted Identity (Clerk) | Built-in organization, role management, and admin dashboards eliminate months of custom development. | Moderate subscription cost; accelerates time-to-market. |
| Legacy v4 Migration | Self-Managed Identity (Auth.js v5) | Maintains architectural continuity; v5 factory pattern simplifies incremental upgrades. | Low migration friction; preserves existing database investments. |
| High-Volume Public App | Self-Managed Identity (Auth.js v5) | Avoids per-user pricing tiers; scales horizontally with your own database infrastructure. | Infrastructure cost scales with traffic; zero vendor lock-in. |
Configuration Template
Auth.js v5 Production Setup
// src/identity/config.ts
import { createAuthConfig } from "next-auth";
import { postgresAdapter } from "@auth/pg-adapter";
import { db } from "@/lib/database";
import { githubProvider } from "next-auth/providers/github";
import { credentialsProvider } from "next-auth/providers/credentials";
export const identityConfig = createAuthConfig({
adapter: postgresAdapter(db),
providers: [
githubProvider({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! }),
credentialsProvider({
name: "credentials",
credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" } },
async authorize(credentials) {
const user = await db.user.findUnique({ where: { email: credentials?.email } });
if (user && await verifyPassword(credentials!.password, user.passwordHash)) {
return { id: user.id, email: user.email, name: user.name };
}
return null;
},
}),
],
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60, updateAge: 24 * 60 * 60 },
callbacks: {
async session({ session, token }) {
session.user.id = token.sub as string;
session.user.role = token.role as string;
return session;
},
},
pages: { signIn: "/auth/login", error: "/auth/error" },
});
export const { handlers: routeHandlers, auth: getSession } = identityConfig;
Clerk Production Setup
// src/identity/middleware.ts
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware(async (auth, req) => {
const protectedPaths = ["/dashboard", "/settings", "/api/internal"];
const isProtected = protectedPaths.some((path) => req.nextUrl.pathname.startsWith(path));
if (isProtected) {
await auth.protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js|json|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
Quick Start Guide
- Initialize the project: Run
npx create-next-app@latest my-auth-app --typescript --tailwind --app. Navigate into the directory.
- Install your chosen identity stack: For self-managed, run
npm install next-auth@beta @auth/pg-adapter. For hosted, run npm install @clerk/nextjs.
- Create the configuration file: Copy the relevant template from the Configuration Template section into
src/identity/config.ts (Auth.js) or src/identity/middleware.ts (Clerk).
- Wire the route handlers: Create
src/app/api/auth/[...identity]/route.ts and export the handlers from your configuration. For Clerk, wrap your root layout with <ClerkProvider> and import the <Show> components for conditional rendering.
- Verify locally: Start the dev server with
npm run dev. Navigate to a protected route. Confirm that unauthenticated requests redirect to the sign-in flow, and authenticated requests render protected content. Check browser dev tools to ensure session cookies or tokens are issued correctly.