Back to blog
Building Next Auth Kit: A Production-Ready Authentication System with Next.js 15 and JWT
A technical deep-dive into building a secure, scalable authentication boilerplate with Next.js 15, TypeScript, MongoDB, and automatic token refresh—from architecture to deployment.
30 min read·Talha Bilal
Share:

Introduction
Every production application requires user authentication, yet implementing it securely remains one of the most complex foundational tasks in web development. Developers face critical decisions: session-based vs. token-based auth, password storage strategies, token refresh mechanisms, email verification flows, and route protection patterns. Most authentication libraries are either too opinionated (limiting customization) or too minimal (leaving critical security gaps).
Next Auth Kit addresses this challenge by providing a complete, production-ready authentication boilerplate that implements industry-standard security practices without abstracting away the underlying mechanisms. The system features JWT-based dual-token authentication with automatic refresh at three architectural levels, email verification, password reset flows, protected route middleware, and a responsive dashboard—all built with modern technologies and optimized for developer experience.
This case study explores the engineering decisions, security considerations, and implementation patterns behind building a authentication system that balances security, scalability, and maintainability.
Screenshot Placeholder
TODO: Add screenshot of the Next Auth Kit landing page showing the hero section with feature highlights and gradient theme.
The Problem Space
Requirements Analysis
Authentication is a solved problem in theory, but implementation details make or break production systems. The platform needed to address several interconnected challenges:
- Secure token management — Implement JWT authentication resistant to XSS and CSRF attacks
- Automatic token refresh — Seamlessly renew expired tokens without user intervention
- Multi-level protection — Guard routes at middleware, API, and client layers
- Email verification — Prevent fake accounts with time-limited verification tokens
- Password security — Store passwords with industry-standard hashing
- Session persistence — Support "Remember Me" with different token lifetimes
- Production readiness — Handle edge cases: token expiry during requests, concurrent refresh attempts, middleware race conditions
Technical Constraints
- Security first — Authentication bugs can compromise entire applications; every decision must prioritize security
- Zero external dependencies — No reliance on third-party auth services (NextAuth, Auth0, Clerk) to maintain full control
- Framework limitations — Next.js middleware runs before page rendering, requiring careful token handling
- Cookie management — HttpOnly cookies prevent JavaScript access but complicate client-side token refresh
- Developer experience — The system must be easy to understand, extend, and debug for teams adopting the boilerplate
Solution Architecture
Technology Selection
The stack was chosen to maximize security, type safety, and production readiness:
| Layer | Technology | Rationale |
|---|---|---|
| Framework | Next.js 15 (App Router) | Built-in API routes, middleware support, React Server Components, and Turbopack for fast development |
| Language | TypeScript 6 | Full type safety across client and server reduces runtime authentication errors |
| Database | MongoDB + Mongoose 9.4 | Flexible schema for user data, excellent TypeScript support, and mature ecosystem |
| JWT Library | jose 6.2 | Modern ES modules library with HS256 signing, recommended over legacy jsonwebtoken |
| Password Hashing | bcryptjs 3.0 | Industry-standard with configurable salt rounds; pure JavaScript (no native dependencies) |
| Email Service | Resend 6.10 | Modern transactional email API with React templates and reliable delivery |
| Validation | Zod (via TypeScript) | Runtime type validation for API inputs prevents malformed requests |
| Styling | Tailwind CSS 4.2 | Utility-first framework with JIT compiler; latest version with native CSS support |
High-Level Architecture
The application follows a layered architecture with authentication checks at multiple levels:
text
1┌─────────────────────────────────────────────────────────────┐2│ Client (React Components) │3│ ┌──────────────────┐ ┌──────────────────────┐ │4│ │ Auth Pages │ │ Dashboard Pages │ │5│ │ (Login, Signup) │ │ (Protected Routes) │ │6│ └──────────────────┘ └──────────────────────┘ │7└────────────────────────────────────┬────────────────────────┘8 │9 ┌────────────────▼────────────────┐10 │ Next.js Middleware Layer │11 │ (Token Validation & Refresh) │12 │ src/proxy.ts │13 └────────────────┬────────────────┘14 │15┌────────────────────────────────────▼────────────────────────┐16│ API Route Handlers │17│ /api/users/login /api/users/register /api/users/token │18│ /api/users/profile /api/users/logout /api/users/* │19└────────────────────────────────────┬────────────────────────┘20 │21 ┌────────────────┼────────────────┐22 │ │ │23 ┌───────▼──────┐ ┌─────▼─────┐ ┌──────▼──────┐24 │ Helpers │ │ Models │ │ Lib │25 │ (JWT, Email) │ │ (User) │ │ (Database) │26 └──────┬───────┘ └───────────┘ └─────────────┘27 │28 ┌─────────┼─────────┬──────────────┐29 │ │ │ │30 ┌────▼───┐ ┌──▼───┐ ┌───▼────┐ ┌──────▼──────┐31 │MongoDB │ │ Jose │ │bcryptjs│ │ Resend │32 │(Users) │ │ JWT │ │ Hash │ │ (Email) │33 └────────┘ └──────┘ └────────┘ └─────────────┘Diagram Placeholder
TODO: Add a Mermaid architecture diagram showing the complete authentication system with client, middleware, API layer, service layer, and data persistence (MongoDB, JWT, Resend).
Core Implementation Details
1. Dual-Token JWT Authentication
Traditional single-token systems force users to re-authenticate frequently (for security) or stay logged in indefinitely (poor security). Next Auth Kit implements a dual-token pattern that balances security and user experience:
Access Token (Short-Lived):
- Lifetime: 1 hour
- Storage: HttpOnly cookie named
token - Purpose: Authenticates API requests and page access
- Contents:
typescript1{2 userId: "507f1f77bcf86cd799439011",3 username: "johndoe",4 email: "john@example.com",5 isVerified: true,6 iat: 1719745200, // issued at timestamp7 exp: 1719748800 // expiration timestamp8}
Refresh Token (Long-Lived):
- Lifetime: 7 days (default) or 30 days (with "Remember Me")
- Storage: HttpOnly cookie named
refreshToken - Purpose: Issues new access tokens without re-login
- Contents:
typescript1{2 userId: "507f1f77bcf86cd799439011",3 iat: 1719745200,4 exp: 17203500005}
Token Generation (
src/app/api/users/login/route.ts):typescript
1import { SignJWT } from "jose";2
3// Create access token with full user data4const accessToken = await new SignJWT({5 userId: user._id.toString(),6 username: user.username,7 email: user.email,8 isVerified: user.isVerified,9})10 .setProtectedHeader({ alg: "HS256" })11 .setIssuedAt()12 .setExpirationTime("1h")13 .sign(new TextEncoder().encode(process.env.TOKEN_SECRET));14
15// Create refresh token with minimal data16const refreshToken = await new SignJWT({ 17 userId: user._id.toString() 18})19 .setProtectedHeader({ alg: "HS256" })20 .setIssuedAt()21 .setExpirationTime(rememberMe ? "30d" : "7d")22 .sign(new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET));23
24// Set secure cookies25response.cookies.set("token", accessToken, {26 httpOnly: true,27 secure: true,28 sameSite: "none",29 maxAge: 60 * 60 // 1 hour in seconds30});31
32response.cookies.set("refreshToken", refreshToken, {33 httpOnly: true,34 secure: true,35 sameSite: "none",36 maxAge: rememberMe ? 30 * 24 * 60 * 60 : 7 * 24 * 60 * 6037});Security Properties:
- HttpOnly flag — Tokens inaccessible to JavaScript, preventing XSS theft
- Secure flag — Cookies only transmitted over HTTPS in production
- SameSite=none — Required for cross-origin requests (adjust to
strictfor same-origin deployments) - Separate secrets — Access and refresh tokens signed with different keys; compromising one doesn't expose the other
This approach provides enterprise-grade security while maintaining seamless UX. Users remain authenticated for days/weeks without re-login, but individual requests are authorized with short-lived tokens.
Screenshot Placeholder
TODO: Add screenshot of the login page showing the email/password form with "Remember Me" checkbox and password visibility toggle.
2. Three-Level Automatic Token Refresh
The most innovative aspect of Next Auth Kit is automatic token refresh at three architectural levels, ensuring seamless authentication regardless of where expiry occurs:
Level 1: Middleware (Request Interception)
File:
src/proxy.tsThe middleware intercepts every request to protected routes before rendering. This is the first line of defense:
typescript
1import { NextRequest, NextResponse } from "next/server";2import { refreshAccessToken, isTokenExpired, isRefreshTokenValid } from "@/helpers/refreshToken";3
4const PUBLIC_PATHS = ["/", "/user/login", "/user/register", "/user/verify-token", ...];5const AUTH_PATHS = ["/user/login", "/user/register", ...];6
7export default async function middleware(req: NextRequest) {8 const path = req.nextUrl.pathname;9 const token = req.cookies.get("token")?.value;10 const refreshToken = req.cookies.get("refreshToken")?.value;11
12 // Redirect authenticated users away from auth pages13 if (AUTH_PATHS.includes(path) && (token || refreshToken)) {14 if (token && !(await isTokenExpired(token))) {15 return NextResponse.redirect(new URL("/user/dashboard", req.url));16 }17 if (refreshToken && (await isRefreshTokenValid(refreshToken))) {18 return NextResponse.redirect(new URL("/user/dashboard", req.url));19 }20 }21
22 // Allow public paths23 if (PUBLIC_PATHS.includes(path)) {24 return NextResponse.next();25 }26
27 // No tokens → redirect to login28 if (!token && !refreshToken) {29 return NextResponse.redirect(new URL("/user/login", req.url));30 }31
32 // Access token expired but refresh token valid → refresh silently33 if (!token && refreshToken) {34 const refreshResult = await refreshAccessToken(req);35 if (refreshResult.success && refreshResult.token) {36 const response = NextResponse.next();37 response.cookies.set("token", refreshResult.token, {38 httpOnly: true,39 secure: true,40 sameSite: "none",41 maxAge: 60 * 6042 });43 return response;44 } else {45 // Refresh failed → clear cookies and redirect46 const response = NextResponse.redirect(new URL("/user/login", req.url));47 response.cookies.delete("refreshToken");48 return response;49 }50 }51
52 // Access token exists → check expiry53 if (token) {54 const tokenExpired = await isTokenExpired(token);55 if (tokenExpired && refreshToken) {56 const refreshResult = await refreshAccessToken(req);57 if (refreshResult.success && refreshResult.token) {58 const response = NextResponse.next();59 response.cookies.set("token", refreshResult.token, {60 httpOnly: true,61 secure: true,62 sameSite: "none",63 maxAge: 60 * 6064 });65 return response;66 } else {67 const response = NextResponse.redirect(new URL("/user/login", req.url));68 response.cookies.delete("token");69 response.cookies.delete("refreshToken");70 return response;71 }72 } else if (tokenExpired && !refreshToken) {73 const response = NextResponse.redirect(new URL("/user/login", req.url));74 response.cookies.delete("token");75 return response;76 }77 }78
79 return NextResponse.next();80}81
82export const config = {83 matcher: ["/((?!_next|api|static|favicon.ico).*)"]84};Token Refresh Helper (
src/helpers/refreshToken.ts):typescript
1import { jwtVerify, SignJWT } from "jose";2
3export async function refreshAccessToken(req: NextRequest) {4 try {5 const refreshToken = req.cookies.get("refreshToken")?.value;6 if (!refreshToken) {7 return { success: false, error: "No refresh token" };8 }9
10 // Verify refresh token11 const refreshSecret = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET);12 const { payload: decoded } = await jwtVerify(refreshToken, refreshSecret);13
14 if (!decoded || !decoded.userId) {15 return { success: false, error: "Invalid refresh token" };16 }17
18 // Create new access token (simplified for middleware speed)19 const newAccessToken = await new SignJWT({20 userId: decoded.userId,21 type: "access",22 })23 .setProtectedHeader({ alg: "HS256" })24 .setIssuedAt()25 .setExpirationTime("1h")26 .sign(new TextEncoder().encode(process.env.TOKEN_SECRET));27
28 return { success: true, token: newAccessToken };29 } catch (error: any) {30 return { success: false, error: "Failed to refresh token" };31 }32}33
34export async function isTokenExpired(token: string): Promise<boolean> {35 try {36 const secret = new TextEncoder().encode(process.env.TOKEN_SECRET);37 await jwtVerify(token, secret);38 return false; // Verification succeeded → not expired39 } catch (error: any) {40 return true; // Verification failed → expired or invalid41 }42}43
44export async function isRefreshTokenValid(refreshToken: string): Promise<boolean> {45 try {46 const secret = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET);47 await jwtVerify(refreshToken, secret);48 return true;49 } catch (error: any) {50 return false;51 }52}Advantages:
- Invisible to user — Token refresh happens before page render
- No JavaScript required — Pure server-side logic
- Handles navigation — Works for all route transitions (link clicks, back button, direct URL entry)
Level 2: API Endpoint (Explicit Refresh)
File:
src/app/api/users/token/route.tsThis endpoint provides explicit token refresh for client-triggered scenarios:
typescript
1export async function POST(req: NextRequest) {2 try {3 await connectDB();4
5 const refreshToken = req.cookies.get("refreshToken")?.value;6 if (!refreshToken) {7 return NextResponse.json(8 { error: "Refresh token not found.", success: false },9 { status: 401 }10 );11 }12
13 // Verify refresh token14 const secret = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET);15 const { payload: decoded } = await jwtVerify(refreshToken, secret);16
17 if (!decoded || !decoded.userId) {18 return NextResponse.json(19 { error: "Invalid refresh token.", success: false },20 { status: 401 }21 );22 }23
24 // Fetch full user data from database25 const user = await User.findById(decoded.userId);26 if (!user) {27 return NextResponse.json(28 { error: "User not found.", success: false },29 { status: 404 }30 );31 }32
33 if (!user.isVerified) {34 return NextResponse.json(35 { error: "User is not verified.", success: false },36 { status: 403 }37 );38 }39
40 // Generate new access token with complete user data41 const tokenData: TokenData = {42 userId: user._id.toString(),43 username: user.username as string,44 email: user.email as string,45 isVerified: user.isVerified as boolean,46 };47
48 const newAccessToken = await new SignJWT({ ...tokenData })49 .setProtectedHeader({ alg: "HS256" })50 .setIssuedAt()51 .setExpirationTime("1h")52 .sign(new TextEncoder().encode(process.env.TOKEN_SECRET));53
54 const response = NextResponse.json({55 message: "Access token refreshed successfully.",56 success: true,57 });58
59 response.cookies.set("token", newAccessToken, {60 httpOnly: true,61 secure: true,62 sameSite: "none",63 maxAge: 60 * 60,64 });65
66 return response;67 } catch (error: any) {68 return NextResponse.json(69 { error: "Failed to generate user token.", success: false },70 { status: 500 }71 );72 }73}Advantages:
- Full user data — Unlike middleware (which uses minimal data for speed), this fetches complete user profile
- Verification check — Ensures user is still verified before issuing token
- Client control — Frontend can proactively refresh before expiration
Level 3: Client-Side API Wrapper
File:
src/helpers/apiUtils.tsA client-side helper automatically retries failed requests after token refresh:
typescript
1export async function apiCall(url: string, options: RequestInit = {}) {2 const makeRequest = async (retryCount = 0): Promise<Response> => {3 const response = await fetch(url, {4 ...options,5 credentials: "include", // Include cookies6 });7
8 // If 401 and first attempt, try refreshing token9 if (response.status === 401 && retryCount === 0) {10 const refreshResponse = await fetch("/api/users/token", {11 method: "POST",12 credentials: "include",13 });14
15 if (refreshResponse.ok) {16 // Token refreshed successfully, retry original request17 return makeRequest(1);18 } else {19 // Refresh failed, redirect to login20 window.location.href = "/user/login";21 throw new Error("Authentication failed");22 }23 }24
25 return response;26 };27
28 return makeRequest();29}Usage in components:
typescript
1// Instead of raw fetch2const response = await apiCall("/api/users/profile", {3 method: "GET"4});5const data = await response.json();Advantages:
- Automatic retry — Failed API calls due to expired tokens automatically retry after refresh
- Single location — All API calls benefit from refresh logic
- Graceful degradation — Redirects to login only after refresh fails
Diagram Placeholder
TODO: Add a Mermaid sequence diagram showing the complete token refresh flow across all three levels: User Request → Middleware Check → Token Expired → Refresh Token Verification → New Access Token → Request Continues.
3. Email Verification System
Preventing fake accounts requires email verification. Next Auth Kit implements time-limited tokens with secure generation:
Token Generation (
src/helpers/mailer.ts):typescript
1import { Resend } from "resend";2import bcrypt from "bcryptjs";3import User from "@/models/userModel";4import EmailTemplate from "../../email/template";5
6export const sendEmail = async (7 EmailType: string,8 subject: string,9 Email: string,10 userId: string11) => {12 try {13 await connectDB();14 const resend = new Resend(process.env.RESEND_API_KEY as string);15
16 // Generate token: bcrypt hash of userId (10 salt rounds)17 const hashedUserId = await bcrypt.hash(userId, 10);18
19 if (EmailType === "VERIFY") {20 const user = await User.findById(userId);21 if (!user) throw new Error("User not found");22 23 user.verificationToken = hashedUserId;24 user.verificationTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes25 await user.save();26 } else if (EmailType === "FORGOT_PASSWORD") {27 const user = await User.findById(userId);28 if (!user) throw new Error("User not found");29 30 user.forgetToken = hashedUserId;31 user.forgetTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes32 await user.save();33 }34
35 const { data, error } = await resend.emails.send({36 from: `Acme <no-reply@${process.env.FROM_EMAIL_DOMAIN}>`,37 to: Email,38 subject: `${EmailType} - ${subject}`,39 react: EmailTemplate({40 emailType: EmailType,41 Subject: subject,42 token: hashedUserId,43 }),44 });45
46 if (error) throw new Error(error as any);47 return data;48 } catch (error: any) {49 console.error(error);50 return null;51 }52};Email Template (
email/template.tsx):typescript
1export default function EmailTemplate({2 emailType,3 Subject,4 token,5}: {6 emailType: string;7 Subject: string;8 token: string;9}) {10 const actionUrl =11 emailType === "VERIFY"12 ? `${process.env.DOMAIN}/user/verify-token?token=${token}&type=email`13 : `${process.env.DOMAIN}/user/reset-password/verify?token=${token}&type=reset-password`;14
15 return (16 <div>17 <h2>{Subject}</h2>18 <p>Click the link below:</p>19 <a href={actionUrl}>{actionUrl}</a>20 </div>21 );22}Token Verification (
src/app/api/users/user-verify/route.ts):typescript
1export async function POST(req: NextRequest) {2 try {3 await connectDB();4
5 const { token } = await req.json();6 if (!token) {7 return NextResponse.json(8 { error: "Token is required.", success: false },9 { status: 400 }10 );11 }12
13 // Find user by token and check expiry14 const user = await User.findOne({15 verificationToken: token,16 verificationTokenExpiry: { $gt: new Date() },17 });18
19 if (!user) {20 return NextResponse.json(21 { error: "Invalid or expired token.", success: false },22 { status: 400 }23 );24 }25
26 if (user.isVerified) {27 return NextResponse.json(28 { error: "User is already verified.", success: false },29 { status: 400 }30 );31 }32
33 // Verify user and clear token34 user.isVerified = true;35 user.verificationToken = undefined;36 user.verificationTokenExpiry = undefined;37 await user.save();38
39 return NextResponse.json({40 message: "User verified successfully.",41 success: true,42 });43 } catch (error: any) {44 return NextResponse.json(45 { error: "Failed to verify user.", success: false },46 { status: 500 }47 );48 }49}Security properties:
- Bcrypt hashing — Tokens are hashed before storage; compromising the database doesn't expose valid tokens
- Time-limited — 10-minute expiry balances security with user convenience
- Single-use — Token cleared after verification prevents replay attacks
- Separate fields —
verificationTokenfor email verification,forgetTokenfor password reset
Screenshot Placeholder
TODO: Add screenshot of the email verification page showing the success message after clicking the verification link.
4. Password Security
Passwords are the primary attack vector in authentication systems. Next Auth Kit implements defense-in-depth:
Password Hashing (
src/app/api/users/register/route.ts):typescript
1import bcrypt from "bcryptjs";2
3// During registration4const hashedPassword = await bcrypt.hash(password, 10);5
6const newUser = new User({7 firstname,8 lastname,9 username,10 email,11 password: hashedPassword,12});13
14await newUser.save();Password Verification (
src/app/api/users/login/route.ts):typescript
1// Find user with password field explicitly included2const user = await User.findOne({ email }).select("+password +isVerified");3
4if (!user) {5 return NextResponse.json(6 { error: "User not found.", success: false },7 { status: 404 }8 );9}10
11// Compare submitted password with hashed password12const isPasswordValid = await bcrypt.compare(password, user.password);13if (!isPasswordValid) {14 return NextResponse.json(15 { error: "Invalid password.", success: false },16 { status: 401 }17 );18}User Model (
src/models/userModel.ts):typescript
1const userSchema = new Schema<IUser>({2 password: {3 type: String,4 required: [true, "Password is required."],5 minlength: [6, "Password must be at least 6 characters."],6 select: false, // Excluded by default for security7 },8 // ... other fields9});The
select: false directive ensures passwords are never included in queries unless explicitly requested with .select("+password"). This prevents accidental password exposure in API responses.Password Change Flow (
src/app/api/users/change-password/route.ts):typescript
1export async function POST(req: NextRequest) {2 try {3 await connectDB();4
5 const payload: any = await decodeToken(req);6 const userId = payload.userId;7
8 const { currentPassword, newPassword } = await req.json();9
10 // Validation11 if (!userId) {12 return NextResponse.json(13 { error: "Unauthorized. Please log in.", success: false },14 { status: 401 }15 );16 }17
18 if (!currentPassword || !newPassword) {19 return NextResponse.json(20 { error: "Current password and new password are required.", success: false },21 { status: 400 }22 );23 }24
25 // Fetch user with password26 const user = await User.findById(userId).select("+password");27 if (!user) {28 return NextResponse.json(29 { error: "User not found.", success: false },30 { status: 404 }31 );32 }33
34 // Verify current password35 const isMatch = await bcrypt.compare(currentPassword, user.password);36 if (!isMatch) {37 return NextResponse.json(38 { error: "Current password is incorrect.", success: false },39 { status: 400 }40 );41 }42
43 // Ensure new password is different44 if (currentPassword === newPassword) {45 return NextResponse.json(46 { error: "New password must be different from current password.", success: false },47 { status: 400 }48 );49 }50
51 // Hash and save new password52 const hashedPassword = await bcrypt.hash(newPassword, 10);53 user.password = hashedPassword;54 await user.save();55
56 return NextResponse.json(57 { message: "Password changed successfully.", success: true },58 { status: 200 }59 );60 } catch (error: any) {61 return NextResponse.json(62 { error: "Internal server error.", success: false },63 { status: 500 }64 );65 }66}Password Reset Flow (
src/app/api/users/forgot-password/verify/route.ts):typescript
1export async function POST(req: NextRequest) {2 try {3 await connectDB();4
5 const { token, password } = await req.json();6
7 if (!token) {8 return NextResponse.json(9 { error: "Token is required.", success: false },10 { status: 400 }11 );12 }13
14 // Find user by reset token and check expiry15 const user = await User.findOne({16 forgetToken: token,17 forgetTokenExpiry: { $gt: new Date() },18 });19
20 if (!user) {21 return NextResponse.json(22 { error: "Invalid or expired token.", success: false },23 { status: 400 }24 );25 }26
27 // Hash new password28 const salt = await bcrypt.genSalt(10);29 const hashedPassword = await bcrypt.hash(password, salt);30
31 user.password = hashedPassword;32 user.forgetToken = undefined;33 user.forgetTokenExpiry = undefined;34 await user.save();35
36 return NextResponse.json(37 { message: "Password reset successfully.", success: true },38 { status: 200 }39 );40 } catch (error: any) {41 return NextResponse.json(42 { error: "An error occurred while processing your request.", success: false },43 { status: 500 }44 );45 }46}Screenshot Placeholder
TODO: Add screenshot of the dashboard security settings page showing the change password form with current password, new password, and confirm password fields.
5. Database Schema Design
MongoDB with Mongoose provides flexible schemas with TypeScript type safety:
User Model (
src/models/userModel.ts):typescript
1import mongoose, { Schema, model, Document, Model, models } from "mongoose";2
3export interface IUser extends Document {4 firstname: string;5 lastname: string;6 bio?: string;7 username: string;8 email: string;9 _id: mongoose.Types.ObjectId;10 password: string;11 createdAt: Date;12 isVerified: boolean;13 isAdmin: boolean;14 forgetToken?: string;15 forgetTokenExpiry?: Date;16 verificationToken?: string;17 verificationTokenExpiry?: Date;18 refreshToken?: string;19}20
21const userSchema = new Schema<IUser>({22 firstname: {23 type: String,24 required: [true, "First name is required."],25 minlength: [2, "First name must be at least 2 characters."],26 maxlength: [30, "First name must be at most 30 characters."],27 trim: true,28 },29 lastname: {30 type: String,31 required: [true, "Last name is required."],32 minlength: [2, "Last name must be at least 2 characters."],33 maxlength: [30, "Last name must be at most 30 characters."],34 trim: true,35 },36 bio: {37 type: String,38 required: false,39 maxlength: [160, "Bio must be at most 160 characters."],40 trim: true,41 },42 username: {43 type: String,44 required: [true, "Username is required."],45 minlength: [3, "Username must be at least 3 characters."],46 maxlength: [30, "Username must be at most 30 characters."],47 trim: true,48 unique: true,49 },50 email: {51 type: String,52 required: [true, "Email is required."],53 unique: true,54 lowercase: true,55 trim: true,56 match: [/\S+@\S+\.\S+/, "Email is invalid."],57 },58 password: {59 type: String,60 required: [true, "Password is required."],61 minlength: [6, "Password must be at least 6 characters."],62 select: false, // Security: exclude by default63 },64 isVerified: {65 type: Boolean,66 default: false,67 },68 isAdmin: {69 type: Boolean,70 default: false,71 },72 forgetToken: {73 type: String,74 select: false, // Security: exclude by default75 },76 forgetTokenExpiry: {77 type: Date,78 select: false,79 },80 verificationToken: {81 type: String,82 select: false,83 },84 verificationTokenExpiry: {85 type: Date,86 select: false,87 },88 refreshToken: {89 type: String,90 select: false,91 },92 createdAt: {93 type: Date,94 default: Date.now,95 },96});97
98// Prevent "Model already compiled" error in Next.js hot reloading99const User: Model<IUser> = models.User100 ? (models.User as Model<IUser>)101 : model<IUser>("User", userSchema);102
103export default User;Database Connection (
src/lib/db.ts):typescript
1import mongoose from "mongoose";2
3const MONGODB_URI = process.env.MONGODB_URI as string;4
5if (!MONGODB_URI) {6 throw new Error("Please define the MONGODB_URI in .env.local");7}8
9// Global singleton pattern for connection pooling10let cached = (global as any).mongoose;11
12if (!cached) {13 cached = (global as any).mongoose = { conn: null, promise: null };14}15
16export async function connectDB(): Promise<typeof mongoose> {17 if (cached.conn) return cached.conn;18
19 if (!cached.promise) {20 cached.promise = mongoose21 .connect(MONGODB_URI, {22 bufferCommands: false,23 })24 .then((mongoose) => {25 console.log("✅ Connected to MongoDB");26 return mongoose;27 });28 }29
30 cached.conn = await cached.promise;31 return cached.conn;32}The singleton pattern prevents connection exhaustion during Next.js hot reloading in development and provides connection pooling in production.
Security Considerations
1. XSS Protection via HttpOnly Cookies
JavaScript-accessible storage (localStorage, sessionStorage) is vulnerable to XSS attacks. HttpOnly cookies prevent JavaScript from reading tokens:
typescript
1response.cookies.set("token", accessToken, {2 httpOnly: true, // Inaccessible to document.cookie3 secure: true, // HTTPS-only in production4 sameSite: "none", // Adjust based on deployment5 maxAge: 36006});Even if an attacker injects malicious JavaScript, tokens remain protected.
2. CSRF Protection via SameSite Cookies
Cross-Site Request Forgery attacks trick users' browsers into making unauthorized requests. The
sameSite attribute prevents this:"strict"— Cookie never sent in cross-origin requests (recommended for same-origin apps)"lax"— Cookie sent with top-level navigation but not embedded requests"none"— Cookie sent with all requests (requiressecure: true)
Next Auth Kit uses
"none" for flexibility, but production deployments should evaluate their CORS requirements.3. Separate Token Secrets
Access and refresh tokens use different signing keys:
typescript
1// Access token2.sign(new TextEncoder().encode(process.env.TOKEN_SECRET));3
4// Refresh token5.sign(new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET));Compromising one secret doesn't expose the other. Generate strong secrets with:
bash
1openssl rand -base64 644. Password Hashing with bcrypt
Bcrypt is purpose-built for password hashing:
- Salt rounds — 10 rounds balance security and performance
- Slow by design — Computationally expensive to brute force
- Adaptive — Can increase rounds as hardware improves
typescript
1const hashedPassword = await bcrypt.hash(password, 10);2const isValid = await bcrypt.compare(plainPassword, hashedPassword);5. Input Validation at API Boundaries
Every API endpoint validates inputs before processing. TypeScript provides compile-time safety, but runtime validation prevents malicious payloads:
typescript
1if (!email || !password) {2 return NextResponse.json(3 { error: "Email and password are required.", success: false },4 { status: 400 }5 );6}7
8if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {9 return NextResponse.json(10 { error: "Invalid email format.", success: false },11 { status: 400 }12 );13}6. Token Expiry Enforcement
Short-lived access tokens (1 hour) limit the blast radius of token theft. Even if an attacker steals an access token, it expires quickly. Refresh tokens (7-30 days) require periodic re-authentication.
The
verificationTokenExpiry and forgetTokenExpiry fields ensure email tokens expire after 10 minutes, preventing indefinite token validity.Performance Optimizations
1. Database Connection Pooling
The singleton pattern in
src/lib/db.ts prevents creating multiple connections:typescript
1let cached = (global as any).mongoose;2
3if (!cached) {4 cached = (global as any).mongoose = { conn: null, promise: null };5}6
7export async function connectDB() {8 if (cached.conn) return cached.conn; // Reuse existing connection9 10 if (!cached.promise) {11 cached.promise = mongoose.connect(MONGODB_URI, {12 bufferCommands: false,13 });14 }15 16 cached.conn = await cached.promise;17 return cached.conn;18}This is critical in serverless environments (Vercel, AWS Lambda) where cold starts would otherwise create connection storms.
2. Middleware Token Refresh (Simplified Payload)
Middleware-level token refresh uses a lightweight payload to minimize processing:
typescript
1// Middleware: minimal data for speed2const newAccessToken = await new SignJWT({3 userId: decoded.userId,4 type: "access",5})6 .setProtectedHeader({ alg: "HS256" })7 .setIssuedAt()8 .setExpirationTime("1h")9 .sign(secret);The full token refresh endpoint (
/api/users/token) fetches complete user data from the database, but middleware avoids this overhead for performance.3. Turbopack for Development Speed
Next.js 15 with Turbopack (
pnpm dev --turbopack) provides significantly faster hot reloading:json
1{2 "scripts": {3 "dev": "next dev --turbopack"4 }5}This improves developer experience during authentication flow testing.
4. Selective Field Loading
Mongoose's
select: false on sensitive fields prevents unnecessary data transfer:typescript
1// Default query excludes password and tokens2const user = await User.findById(userId);3
4// Explicitly include when needed5const user = await User.findById(userId).select("+password");This reduces payload size and improves query performance.
Developer Experience
1. TypeScript Across the Stack
Type safety eliminates entire classes of runtime errors:
typescript
1// Database layer2export interface IUser extends Document {3 email: string;4 password: string;5 isVerified: boolean;6}7
8// API layer9const user: IUser = await User.findById(userId);10
11// Frontend layer12type UserProfile = {13 username: string;14 email: string;15 isVerified: boolean;16};IDEs provide autocomplete and catch type mismatches before runtime.
2. Path Aliases
The
@/* alias eliminates relative import complexity:typescript
1// Instead of: import { connectDB } from "../../../lib/db"2import { connectDB } from "@/lib/db";3import { User } from "@/models/userModel";4import { decodeToken } from "@/helpers/decodeToken";Configured in
tsconfig.json:json
1{2 "compilerOptions": {3 "paths": {4 "@/*": ["./src/*"]5 }6 }7}3. Standardized API Response Format
All endpoints use consistent response shapes:
typescript
1// Success2{3 success: true,4 message: "User registered successfully.",5 data: { user: { ... } }6}7
8// Error9{10 success: false,11 error: "Invalid credentials."12}Clients can uniformly check
success flags without parsing different error formats.4. Environment Variable Validation
The application fails fast on startup if critical environment variables are missing:
typescript
1const MONGODB_URI = process.env.MONGODB_URI as string;2
3if (!MONGODB_URI) {4 throw new Error("Please define MONGODB_URI in .env.local");5}This prevents cryptic runtime errors in production.
5. Centralized Token Utilities
Token operations are abstracted into reusable helpers:
decodeToken(req)— Extract JWT payload from cookiesrefreshAccessToken(req)— Generate new access tokenisTokenExpired(token)— Check token validityisRefreshTokenValid(token)— Verify refresh token
This keeps API routes clean and DRY.
Screenshot Placeholder
TODO: Add screenshot of the responsive dashboard with sidebar navigation showing Dashboard, Settings, and Logout options with mobile hamburger menu.
Lessons Learned
1. Middleware Execution Order Matters
Next.js middleware runs before page rendering AND API routes. Early versions accidentally blocked API endpoints. The solution: exclude API routes from the matcher:
typescript
1export const config = {2 matcher: ["/((?!_next|api|static|favicon.ico).*)"]3};Takeaway: Carefully configure middleware matchers to avoid blocking internal routes.
2. Token Refresh Requires Separate Secrets
Using the same secret for access and refresh tokens means compromising one compromises both. Separate secrets provide defense-in-depth.
Takeaway: Always use different signing keys for tokens with different lifetimes.
3. Cookie Attributes are Critical
Early testing revealed tokens were lost across requests. The issue: missing
secure and sameSite attributes. Modern browsers require explicit configuration.Takeaway: Test cookie behavior across browsers and deployment environments (localhost vs. production HTTPS).
4. Database Connection Leaks in Development
Next.js hot reloading created multiple MongoDB connections until connection limits were hit. The singleton pattern fixed this.
Takeaway: Always implement connection pooling for database libraries in Next.js.
5. Token Expiry Edge Cases
Users navigating between pages during token expiry caused inconsistent behavior. The middleware-level refresh solved this by handling expiry before page render.
Takeaway: Implement token refresh at the earliest possible point in the request lifecycle.
Technical Challenges and Trade-offs
Challenge 1: Balancing Token Lifetime with Security
Problem: Short-lived tokens improve security but degrade UX. Long-lived tokens are convenient but risky.
Solution: Dual-token system with 1-hour access tokens and 7-30 day refresh tokens. Automatic refresh provides security without user friction.
Trade-off: Increased complexity. Three refresh mechanisms (middleware, API, client) must stay synchronized.
Challenge 2: HttpOnly Cookies vs. Client-Side State
Problem: HttpOnly cookies prevent XSS but make client-side token management impossible.
Solution: Server-side token handling in middleware and API routes. Client never needs to read tokens.
Trade-off: Frontend cannot display "token expires in X minutes" warnings without additional API calls.
Challenge 3: Email Token Expiry Duration
Problem: 5-minute expiry was too short (users complained). 30-minute expiry was too long (security concern).
Solution: 10-minute expiry as a balanced compromise. Users can request new tokens if needed.
Trade-off: Not universally optimal—some users prefer longer windows. Could be configurable per environment.
Challenge 4: Middleware Performance with Database Queries
Problem: Middleware querying the database on every request added latency.
Solution: Middleware uses JWT verification (in-memory) instead of database lookups. Only the explicit refresh endpoint (
/api/users/token) hits the database.Trade-off: Middleware-refreshed tokens have minimal payload (just
userId). Full user data requires separate API call.Production Readiness Checklist
The application implements production-grade patterns:
- Environment-based configuration — All secrets in environment variables
- Database connection pooling — Singleton pattern prevents exhaustion
- Error handling — Try-catch blocks in all async operations
- Input validation — Type checking and format validation on all endpoints
- Authentication — JWT with HttpOnly cookies and separate secrets
- Authorization — Middleware protects routes before rendering
- Password security — bcrypt with 10 salt rounds
- Email verification — Time-limited tokens prevent fake accounts
- Token refresh — Three-level automatic refresh system
- Session management — Configurable token lifetimes ("Remember Me")
- Type safety — TypeScript across entire stack
- CORS handling — Next.js default CORS prevents cross-origin attacks
- Secure cookies — HttpOnly, Secure, SameSite attributes configured
- Rate limiting — Not implemented (future: Redis-based rate limiter)
- Monitoring — No APM integration (future: Sentry for error tracking)
- Audit logging — Authentication events not logged (future: log all auth actions)
- Session revocation — No "logout all devices" feature (future: track active sessions)
Future Enhancements
Based on the current implementation, logical next steps include:
-
OAuth Provider Integration
- Add Google, GitHub, and Microsoft SSO
- Implement OAuth callback handlers
- Link social accounts to existing users
-
Multi-Factor Authentication (MFA)
- TOTP-based 2FA with QR code generation
- SMS-based verification (Twilio integration)
- Backup codes for account recovery
-
Session Management Dashboard
- Display active sessions with device info (user agent, IP)
- Implement "logout all devices" functionality
- Show last login timestamps
-
Rate Limiting
- Implement Redis-based rate limiter (10 login attempts per IP per hour)
- Protect registration endpoint from spam
- Add CAPTCHA on repeated failures
-
Audit Logging
- Log all authentication events (login, logout, token refresh)
- Store IP addresses and user agents
- Provide users with account activity history
-
Refresh Token Rotation
- Issue new refresh token on each use
- Invalidate old refresh tokens
- Detect token reuse attacks
-
Admin Dashboard
- View user registrations and verification rates
- Monitor authentication failures
- Manage user accounts (verify, disable, delete)
-
Email Templates
- Design professional HTML email templates
- Add branded logos and styling
- Support dark mode email clients
Deployment Considerations
Recommended Platforms
The application is deployment-ready for:
- Vercel — Zero-config with automatic HTTPS, CDN, and MongoDB Atlas integration
- Netlify — Simple deployment with environment variable management
- Railway — One-click deploy with automatic MongoDB provisioning
- AWS — Elastic Beanstalk for managed containers or Amplify for serverless
Pre-Deployment Checklist
-
MongoDB Setup
- Create production cluster (Atlas M10+ recommended)
- Whitelist deployment server IP (or 0.0.0.0/0 for serverless)
- Enable automated backups
- Create database user with strong password
-
Environment Variables
- Set
NODE_ENV=production - Generate strong secrets:
bash1node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
- Configure
DOMAINto production URL (e.g.,https://yourapp.com) - Update
FROM_EMAIL_DOMAINto verified Resend domain
- Set
-
Email Configuration
- Verify production domain in Resend dashboard
- Test verification and password reset emails
- Monitor email delivery rates
-
Security Hardening
- Review cookie
sameSitesetting based on CORS requirements - Enable HTTPS-only cookies (
secure: true) - Implement rate limiting (future enhancement)
- Review cookie
-
Performance Testing
- Load test with 100+ concurrent users
- Verify database connection pooling under load
- Monitor response times for token refresh
Diagram Placeholder
TODO: Add a Mermaid flowchart illustrating the complete authentication request lifecycle from user login through JWT generation, cookie setting, middleware validation, and dashboard access.
Key Takeaways
-
Security Requires Layered Defense — HttpOnly cookies, separate JWT secrets, password hashing, input validation, and token expiry work together. No single layer is sufficient.
-
Automatic Token Refresh is Non-Negotiable — Users should never see "session expired" errors. Implementing refresh at three levels (middleware, API, client) ensures seamless UX.
-
Middleware is Powerful but Complex — Next.js middleware provides early-stage authentication but requires careful configuration to avoid blocking necessary routes.
-
Cookie Management is Browser-Dependent — Modern browsers have strict cookie policies. Always test
secure,httpOnly, andsameSiteattributes across deployment environments. -
TypeScript Multiplies Productivity — Type safety across database models, API routes, and frontend components catches errors at compile time and improves refactoring confidence.
-
Connection Pooling Prevents Outages — The singleton pattern for database connections is essential in serverless environments to prevent connection exhaustion.
Conclusion
Next Auth Kit demonstrates how to build a production-grade authentication system by combining modern frameworks (Next.js 15, TypeScript, MongoDB) with proven security patterns (JWT dual-token system, bcrypt password hashing, HttpOnly cookies). The architecture balances security and user experience through three-level automatic token refresh, ensuring users remain authenticated without manual intervention.
The middleware-based approach intercepts requests at the earliest possible point, providing protection before page rendering. Combined with API-level refresh and client-side retry logic, the system handles token expiry gracefully regardless of where it occurs. Email verification and password reset flows implement time-limited tokens with secure generation, preventing common attack vectors.
While the current implementation is production-ready for most applications, there are clear paths for enhancement: OAuth provider integration, multi-factor authentication, session management dashboards, and rate limiting. These improvements represent iterative refinement rather than architectural overhaul—a sign of solid foundational design.
The platform proves that authentication doesn't require external services or complex libraries. A well-structured Next.js application with clear separation of concerns (middleware, API routes, helpers, models) can deliver enterprise-grade security while remaining maintainable and cost-effective.
For teams building authentication systems, this case study illustrates the importance of defense-in-depth security, careful cookie configuration, connection pooling in serverless environments, and automatic token refresh for seamless UX. The code patterns and architectural decisions documented here are applicable across a wide range of full-stack applications requiring secure user authentication.
Need a custom authentication solution for your application? Get in touch to discuss your project.

