DEV Community

Cover image for Next.js JWT Authentication: Complete Guide to Secure Your App in 2026
sizan mahmud0
sizan mahmud0

Posted on

Next.js JWT Authentication: Complete Guide to Secure Your App in 2026

Building secure authentication in Next.js applications can be challenging, especially with the framework's unique server-side and client-side rendering capabilities. In this comprehensive guide, I'll show you how to implement JWT (JSON Web Token) authentication in Next.js 14+ with App Router, covering everything from setup to production-ready security practices.

Why JWT Authentication for Next.js?

Next.js's hybrid rendering model (SSR, SSG, CSR) requires an authentication solution that works seamlessly across server and client components. JWT authentication provides:

Stateless Authentication: Perfect for serverless deployments and edge functions

API Route Protection: Secure your Next.js API routes effortlessly

SSR Compatibility: Works with server-side rendering out of the box

Scalability: No session storage needed, ideal for distributed systems

Mobile-Friendly: Easy integration with React Native or mobile apps

Edge-Ready: Compatible with Next.js Edge Runtime

What We'll Build

By the end of this tutorial, you'll have a fully functional Next.js app with:

  • User registration and login
  • Protected pages and API routes
  • JWT token management with refresh tokens
  • Middleware for authentication
  • Secure cookie storage
  • Logout functionality
  • Client and server-side authentication checks

Prerequisites

Before we start, ensure you have:

  • Node.js 18+ installed
  • Basic understanding of React and Next.js
  • Familiarity with TypeScript (optional but recommended)
  • A backend API or we'll use Next.js API routes

Project Setup

Step 1: Create a Next.js Project

npx create-next-app@latest nextjs-jwt-auth
cd nextjs-jwt-auth
Enter fullscreen mode Exit fullscreen mode

When prompted, select:

  • ✅ TypeScript
  • ✅ ESLint
  • ✅ Tailwind CSS
  • ✅ App Router
  • ❌ Turbopack

Step 2: Install Required Dependencies

npm install jsonwebtoken bcryptjs jose
npm install --save-dev @types/jsonwebtoken @types/bcryptjs
Enter fullscreen mode Exit fullscreen mode

Package Breakdown:

  • jsonwebtoken: JWT creation and verification
  • bcryptjs: Password hashing
  • jose: JWT handling for Edge Runtime (Next.js compatible)

Step 3: Project Structure

Create the following folder structure:

nextjs-jwt-auth/
├── app/
│   ├── api/
│   │   ├── auth/
│   │   │   ├── login/route.ts
│   │   │   ├── register/route.ts
│   │   │   ├── logout/route.ts
│   │   │   └── refresh/route.ts
│   │   └── protected/route.ts
│   ├── login/page.tsx
│   ├── register/page.tsx
│   ├── dashboard/page.tsx
│   ├── layout.tsx
│   └── page.tsx
├── lib/
│   ├── auth.ts
│   ├── db.ts
│   └── types.ts
├── middleware.ts
└── .env.local
Enter fullscreen mode Exit fullscreen mode

Setting Up Environment Variables

Create a .env.local file in your project root:

JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
NODE_ENV=development
Enter fullscreen mode Exit fullscreen mode

Important: Generate strong secrets using:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Enter fullscreen mode Exit fullscreen mode

Creating Type Definitions

Create lib/types.ts:

export interface User {
  id: string;
  email: string;
  name: string;
  password?: string;
  createdAt: Date;
}

export interface JWTPayload {
  userId: string;
  email: string;
  name: string;
  iat?: number;
  exp?: number;
}

export interface LoginCredentials {
  email: string;
  password: string;
}

export interface RegisterData {
  name: string;
  email: string;
  password: string;
}

export interface AuthResponse {
  success: boolean;
  message?: string;
  user?: Omit<User, 'password'>;
  accessToken?: string;
}
Enter fullscreen mode Exit fullscreen mode

Building Authentication Utilities

Create lib/auth.ts:

import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { JWTPayload } from './types';

const secretKey = process.env.JWT_SECRET!;
const refreshSecretKey = process.env.JWT_REFRESH_SECRET!;
const key = new TextEncoder().encode(secretKey);
const refreshKey = new TextEncoder().encode(refreshSecretKey);

// Generate Access Token
export async function generateAccessToken(payload: JWTPayload): Promise<string> {
  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(key);
}

// Generate Refresh Token
export async function generateRefreshToken(payload: JWTPayload): Promise<string> {
  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(refreshKey);
}

// Verify Access Token
export async function verifyAccessToken(token: string): Promise<JWTPayload | null> {
  try {
    const verified = await jwtVerify(token, key);
    return verified.payload as JWTPayload;
  } catch (error) {
    return null;
  }
}

// Verify Refresh Token
export async function verifyRefreshToken(token: string): Promise<JWTPayload | null> {
  try {
    const verified = await jwtVerify(token, refreshKey);
    return verified.payload as JWTPayload;
  } catch (error) {
    return null;
  }
}

// Get token from cookies
export async function getTokenFromCookies(): Promise<string | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('accessToken');
  return token?.value || null;
}

// Get token from request headers
export function getTokenFromHeaders(request: NextRequest): string | null {
  const authHeader = request.headers.get('authorization');
  if (authHeader && authHeader.startsWith('Bearer ')) {
    return authHeader.substring(7);
  }
  return null;
}

// Get current user from token
export async function getCurrentUser(request: NextRequest): Promise<JWTPayload | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('accessToken')?.value || getTokenFromHeaders(request);

  if (!token) return null;

  return await verifyAccessToken(token);
}

// Set auth cookies
export async function setAuthCookies(accessToken: string, refreshToken: string) {
  const cookieStore = await cookies();

  cookieStore.set('accessToken', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 15, // 15 minutes
    path: '/',
  });

  cookieStore.set('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: '/',
  });
}

// Clear auth cookies
export async function clearAuthCookies() {
  const cookieStore = await cookies();
  cookieStore.delete('accessToken');
  cookieStore.delete('refreshToken');
}
Enter fullscreen mode Exit fullscreen mode

Simple User Database (In-Memory)

Create lib/db.ts:

import bcrypt from 'bcryptjs';
import { User } from './types';

// In production, use a real database (PostgreSQL, MongoDB, etc.)
const users: Map<string, User> = new Map();

export const db = {
  // Find user by email
  findUserByEmail: async (email: string): Promise<User | null> => {
    for (const user of users.values()) {
      if (user.email === email) {
        return user;
      }
    }
    return null;
  },

  // Find user by ID
  findUserById: async (id: string): Promise<User | null> => {
    return users.get(id) || null;
  },

  // Create new user
  createUser: async (name: string, email: string, password: string): Promise<User> => {
    const existingUser = await db.findUserByEmail(email);
    if (existingUser) {
      throw new Error('User already exists');
    }

    const hashedPassword = await bcrypt.hash(password, 10);
    const userId = Date.now().toString();

    const user: User = {
      id: userId,
      name,
      email,
      password: hashedPassword,
      createdAt: new Date(),
    };

    users.set(userId, user);
    return user;
  },

  // Verify password
  verifyPassword: async (plainPassword: string, hashedPassword: string): Promise<boolean> => {
    return await bcrypt.compare(plainPassword, hashedPassword);
  },

  // Remove password from user object
  sanitizeUser: (user: User): Omit<User, 'password'> => {
    const { password, ...userWithoutPassword } = user;
    return userWithoutPassword;
  },
};
Enter fullscreen mode Exit fullscreen mode

Creating API Routes

Registration Route

Create app/api/auth/register/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { RegisterData } from '@/lib/types';

export async function POST(request: NextRequest) {
  try {
    const body: RegisterData = await request.json();
    const { name, email, password } = body;

    // Validation
    if (!name || !email || !password) {
      return NextResponse.json(
        { success: false, message: 'All fields are required' },
        { status: 400 }
      );
    }

    if (password.length < 6) {
      return NextResponse.json(
        { success: false, message: 'Password must be at least 6 characters' },
        { status: 400 }
      );
    }

    // Create user
    const user = await db.createUser(name, email, password);
    const sanitizedUser = db.sanitizeUser(user);

    return NextResponse.json(
      {
        success: true,
        message: 'User registered successfully',
        user: sanitizedUser,
      },
      { status: 201 }
    );
  } catch (error: any) {
    return NextResponse.json(
      { success: false, message: error.message || 'Registration failed' },
      { status: 400 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Login Route

Create app/api/auth/login/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { generateAccessToken, generateRefreshToken, setAuthCookies } from '@/lib/auth';
import { LoginCredentials } from '@/lib/types';

export async function POST(request: NextRequest) {
  try {
    const body: LoginCredentials = await request.json();
    const { email, password } = body;

    // Validation
    if (!email || !password) {
      return NextResponse.json(
        { success: false, message: 'Email and password are required' },
        { status: 400 }
      );
    }

    // Find user
    const user = await db.findUserByEmail(email);
    if (!user) {
      return NextResponse.json(
        { success: false, message: 'Invalid credentials' },
        { status: 401 }
      );
    }

    // Verify password
    const isValidPassword = await db.verifyPassword(password, user.password!);
    if (!isValidPassword) {
      return NextResponse.json(
        { success: false, message: 'Invalid credentials' },
        { status: 401 }
      );
    }

    // Generate tokens
    const payload = {
      userId: user.id,
      email: user.email,
      name: user.name,
    };

    const accessToken = await generateAccessToken(payload);
    const refreshToken = await generateRefreshToken(payload);

    // Set cookies
    await setAuthCookies(accessToken, refreshToken);

    const sanitizedUser = db.sanitizeUser(user);

    return NextResponse.json(
      {
        success: true,
        message: 'Login successful',
        user: sanitizedUser,
        accessToken,
      },
      { status: 200 }
    );
  } catch (error: any) {
    return NextResponse.json(
      { success: false, message: error.message || 'Login failed' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Refresh Token Route

Create app/api/auth/refresh/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { verifyRefreshToken, generateAccessToken, setAuthCookies, generateRefreshToken } from '@/lib/auth';

export async function POST(request: NextRequest) {
  try {
    const cookieStore = await cookies();
    const refreshToken = cookieStore.get('refreshToken')?.value;

    if (!refreshToken) {
      return NextResponse.json(
        { success: false, message: 'No refresh token provided' },
        { status: 401 }
      );
    }

    const payload = await verifyRefreshToken(refreshToken);
    if (!payload) {
      return NextResponse.json(
        { success: false, message: 'Invalid refresh token' },
        { status: 401 }
      );
    }

    // Generate new tokens
    const newAccessToken = await generateAccessToken({
      userId: payload.userId,
      email: payload.email,
      name: payload.name,
    });

    const newRefreshToken = await generateRefreshToken({
      userId: payload.userId,
      email: payload.email,
      name: payload.name,
    });

    // Set new cookies
    await setAuthCookies(newAccessToken, newRefreshToken);

    return NextResponse.json(
      {
        success: true,
        message: 'Token refreshed successfully',
        accessToken: newAccessToken,
      },
      { status: 200 }
    );
  } catch (error: any) {
    return NextResponse.json(
      { success: false, message: error.message || 'Token refresh failed' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Logout Route

Create app/api/auth/logout/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { clearAuthCookies } from '@/lib/auth';

export async function POST(request: NextRequest) {
  try {
    await clearAuthCookies();

    return NextResponse.json(
      { success: true, message: 'Logged out successfully' },
      { status: 200 }
    );
  } catch (error: any) {
    return NextResponse.json(
      { success: false, message: error.message || 'Logout failed' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Protected API Route Example

Create app/api/protected/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';

export async function GET(request: NextRequest) {
  try {
    const user = await getCurrentUser(request);

    if (!user) {
      return NextResponse.json(
        { success: false, message: 'Unauthorized' },
        { status: 401 }
      );
    }

    return NextResponse.json(
      {
        success: true,
        message: 'This is protected data',
        user,
        data: {
          secretInfo: 'Only authenticated users can see this',
          timestamp: new Date().toISOString(),
        },
      },
      { status: 200 }
    );
  } catch (error: any) {
    return NextResponse.json(
      { success: false, message: error.message || 'Server error' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating Middleware for Route Protection

Create middleware.ts in the root directory:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyAccessToken } from './lib/auth';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('accessToken')?.value;

  // Protected routes
  const protectedPaths = ['/dashboard', '/profile', '/settings'];
  const isProtectedPath = protectedPaths.some(path => 
    request.nextUrl.pathname.startsWith(path)
  );

  // Public routes that should redirect if authenticated
  const authPaths = ['/login', '/register'];
  const isAuthPath = authPaths.some(path => 
    request.nextUrl.pathname.startsWith(path)
  );

  if (isProtectedPath) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    const user = await verifyAccessToken(token);
    if (!user) {
      const response = NextResponse.redirect(new URL('/login', request.url));
      response.cookies.delete('accessToken');
      response.cookies.delete('refreshToken');
      return response;
    }
  }

  if (isAuthPath && token) {
    const user = await verifyAccessToken(token);
    if (user) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/profile/:path*',
    '/settings/:path*',
    '/login',
    '/register',
  ],
};
Enter fullscreen mode Exit fullscreen mode

Building Frontend Components

Login Page

Create app/login/page.tsx:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

export default function LoginPage() {
  const router = useRouter();
  const [formData, setFormData] = useState({
    email: '',
    password: '',
  });
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });

      const data = await response.json();

      if (data.success) {
        router.push('/dashboard');
        router.refresh();
      } else {
        setError(data.message || 'Login failed');
      }
    } catch (error) {
      setError('An error occurred. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Sign in to your account
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          {error && (
            <div className="rounded-md bg-red-50 p-4">
              <p className="text-sm text-red-800">{error}</p>
            </div>
          )}
          <div className="rounded-md shadow-sm -space-y-px">
            <div>
              <label htmlFor="email" className="sr-only">Email address</label>
              <input
                id="email"
                name="email"
                type="email"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Email address"
                value={formData.email}
                onChange={(e) => setFormData({ ...formData, email: e.target.value })}
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">Password</label>
              <input
                id="password"
                name="password"
                type="password"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Password"
                value={formData.password}
                onChange={(e) => setFormData({ ...formData, password: e.target.value })}
              />
            </div>
          </div>

          <div>
            <button
              type="submit"
              disabled={loading}
              className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
            >
              {loading ? 'Signing in...' : 'Sign in'}
            </button>
          </div>

          <div className="text-sm text-center">
            <Link href="/register" className="font-medium text-indigo-600 hover:text-indigo-500">
              Don't have an account? Register
            </Link>
          </div>
        </form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Register Page

Create app/register/page.tsx:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

export default function RegisterPage() {
  const router = useRouter();
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
  });
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    if (formData.password !== formData.confirmPassword) {
      setError('Passwords do not match');
      return;
    }

    setLoading(true);

    try {
      const response = await fetch('/api/auth/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: formData.name,
          email: formData.email,
          password: formData.password,
        }),
      });

      const data = await response.json();

      if (data.success) {
        router.push('/login?registered=true');
      } else {
        setError(data.message || 'Registration failed');
      }
    } catch (error) {
      setError('An error occurred. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Create your account
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          {error && (
            <div className="rounded-md bg-red-50 p-4">
              <p className="text-sm text-red-800">{error}</p>
            </div>
          )}
          <div className="rounded-md shadow-sm space-y-4">
            <input
              type="text"
              required
              className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              placeholder="Full Name"
              value={formData.name}
              onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            />
            <input
              type="email"
              required
              className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              placeholder="Email address"
              value={formData.email}
              onChange={(e) => setFormData({ ...formData, email: e.target.value })}
            />
            <input
              type="password"
              required
              className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              placeholder="Password"
              value={formData.password}
              onChange={(e) => setFormData({ ...formData, password: e.target.value })}
            />
            <input
              type="password"
              required
              className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
              placeholder="Confirm Password"
              value={formData.confirmPassword}
              onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
          >
            {loading ? 'Creating account...' : 'Register'}
          </button>

          <div className="text-sm text-center">
            <Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
              Already have an account? Sign in
            </Link>
          </div>
        </form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Protected Dashboard Page

Create app/dashboard/page.tsx:

'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

interface UserData {
  userId: string;
  email: string;
  name: string;
}

export default function DashboardPage() {
  const router = useRouter();
  const [userData, setUserData] = useState<UserData | null>(null);
  const [protectedData, setProtectedData] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchProtectedData();
  }, []);

  const fetchProtectedData = async () => {
    try {
      const response = await fetch('/api/protected');
      const data = await response.json();

      if (data.success) {
        setUserData(data.user);
        setProtectedData(data.data);
      }
    } catch (error) {
      console.error('Error fetching protected data:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleLogout = async () => {
    try {
      await fetch('/api/auth/logout', { method: 'POST' });
      router.push('/login');
      router.refresh();
    } catch (error) {
      console.error('Logout error:', error);
    }
  };

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-xl">Loading...</div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <nav className="bg-white shadow-sm">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
          <div className="flex justify-between h-16">
            <div className="flex items-center">
              <h1 className="text-xl font-bold">Dashboard</h1>
            </div>
            <div className="flex items-center">
              <button
                onClick={handleLogout}
                className="ml-4 px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
              >
                Logout
              </button>
            </div>
          </div>
        </div>
      </nav>

      <main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
        <div className="px-4 py-6 sm:px-0">
          <div className="bg-white shadow rounded-lg p-6 mb-6">
            <h2 className="text-2xl font-bold mb-4">Welcome, {userData?.name}!</h2>
            <div className="space-y-2">
              <p className="text-gray-600">
                <span className="font-semibold">Email:</span> {userData?.email}
              </p>
              <p className="text-gray-600">
                <span className="font-semibold">User ID:</span> {userData?.userId}
              </p>
            </div>
          </div>

          <div className="bg-white shadow rounded-lg p-6">
            <h3 className="text-xl font-bold mb-4">Protected Data</h3>
            {protectedData && (
              <div className="space-y-2">
                <p className="text-gray-600">{protectedData.secretInfo}</p>
                <p className="text-sm text-gray-500">
                  Last accessed: {new Date(protectedData.timestamp).toLocaleString()}
                </p>
              </div>
            )}
          </div>
        </div>
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Creating a Custom Auth Hook

Create lib/useAuth.ts:

'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';

interface User {
  userId: string;
  email: string;
  name: string;
}

export function useAuth() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const router = useRouter();

  useEffect(() => {
    checkAuth();
  }, []);

  const checkAuth = async () => {
    try {
      const response = await fetch('/api/protected');
      const data = await response.json();

      if (data.success) {
        setUser(data.user);
      } else {
        setUser(null);
      }
    } catch (error) {
      setUser(null);
    } finally {
      setLoading(false);
    }
  };

  const login = async (email: string, password: string) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    const data = await response.json();

    if (data.success) {
      setUser(data.user);
      return { success: true };
    }

    return { success: false, message: data.message };
  };

  const logout = async () => {
    await fetch('/api/auth/logout', { method: 'POST' });
    setUser(null);
    router.push('/login');
  };

  const refreshToken = async () => {
    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
      });

      const data = await response.json();
      return data.success;
    } catch (error) {
      return false;
    }
  };

  return { user, loading, login, logout, refreshToken };
}
Enter fullscreen mode Exit fullscreen mode

Testing Your Authentication

Start the Development Server

npm run dev
Enter fullscreen mode Exit fullscreen mode

Test Flow:

  1. Register: Navigate to http://localhost:3000/register

    • Create a new account
    • Should redirect to login
  2. Login: Go to http://localhost:3000/login

    • Use your credentials
    • Should redirect to dashboard
  3. Dashboard: Access http://localhost:3000/dashboard

    • See your user information
    • View protected data
  4. Logout: Click logout button

    • Should redirect to login
    • Cannot access dashboard

Security Best Practices

1. Token Storage

✅ DO:

  • Store tokens in httpOnly cookies (server-side)
  • Use secure flag in production
  • Set appropriate sameSite attribute

❌ DON'T:

  • Store tokens in localStorage
  • Store tokens in regular cookies accessible via JavaScript
  • Expose tokens in URLs

2. Token Expiration

// Short-lived access tokens
ACCESS_TOKEN_EXPIRY=15m

// Long-lived refresh tokens
REFRESH_TOKEN_EXPIRY=7d
Enter fullscreen mode Exit fullscreen mode

3. HTTPS in Production

Always use HTTPS in production to prevent token interception:

cookieStore.set('accessToken', accessToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
  sameSite: 'lax',
  maxAge: 60 * 15,
  path: '/',
});
Enter fullscreen mode Exit fullscreen mode

4. CSRF Protection

Next.js provides built-in CSRF protection for API routes. Ensure you're using:

export const config = {
  api: {
    bodyParser: true,
  },
};
Enter fullscreen mode Exit fullscreen mode

5. Rate Limiting

Install and configure rate limiting:

npm install @upstash/ratelimit @upstash/redis
Enter fullscreen mode Exit fullscreen mode

6. Environment Variables

Never commit .env.local to version control:

# .gitignore
.env.local
.env.*.local
Enter fullscreen mode Exit fullscreen mode

Production Deployment Checklist

  • [ ] Generate strong JWT secrets
  • [ ] Enable HTTPS/SSL
  • [ ] Set secure cookie flags
  • [ ] Configure CORS properly
  • [ ] Implement rate limiting
  • [ ] Set up monitoring and logging
  • [ ] Use environment variables
  • [ ] Enable token refresh mechanism
  • [ ] Implement token blacklisting (optional)
  • [ ] Add CSP headers
  • [ ] Configure proper error handling
  • [ ] Set up database (replace in-memory storage)

Connecting to a Real Database

Replace the in-memory storage with a real database. Here's an example with Prisma:

npm install prisma @prisma/client
npx prisma init
Enter fullscreen mode Exit fullscreen mode

Update prisma/schema.prisma:

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  password  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Run migrations:

npx prisma migrate dev --name init
npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Advanced Features to Implement

1. Email Verification

Add email verification during registration to ensure valid users.

2. Password Reset

Implement forgot password functionality with time-limited reset tokens.

3. Two-Factor Authentication

Add an extra security layer with 2FA using libraries like speakeasy.

4. Social Authentication

Integrate OAuth providers using NextAuth.js:

npm install next-auth
Enter fullscreen mode Exit fullscreen mode

5. Role-Based Access Control

Extend JWT payload with user roles:

export interface JWTPayload {
  userId: string;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'moderator';
}
Enter fullscreen mode Exit fullscreen mode

Common Issues and Solutions

Issue 1: "Invalid Token" After Refresh

Solution: Implement automatic token refresh in your API calls.

Issue 2: Cookies Not Being Set

Solution: Check cookie settings and ensure domain matches.

Issue 3: Middleware Not Working

Solution: Verify middleware.ts is in the root directory and config matcher is correct.

Issue 4: CORS Errors

Solution: Configure CORS headers in next.config.js:

module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Allow-Origin', value: '*' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

1. Token Caching

Cache verified tokens to reduce computation:

const tokenCache = new Map<string, { payload: JWTPayload; exp: number }>();
Enter fullscreen mode Exit fullscreen mode

2. Edge Runtime

Deploy middleware to Edge for faster authentication checks:

export const config = {
  matcher: ['/dashboard/:path*'],
  runtime: 'edge',
};
Enter fullscreen mode Exit fullscreen mode

3. Lazy Loading

Lazy load authentication check on client:

const { user } = useAuth();
Enter fullscreen mode Exit fullscreen mode

Monitoring and Logging

Implement logging for security events:

// lib/logger.ts
export const logger = {
  login: (email: string, success: boolean) => {
    console.log(`Login attempt: ${email} - ${success ? 'SUCCESS' : 'FAILED'}`);
  },
  tokenRefresh: (userId: string) => {
    console.log(`Token refreshed for user: ${userId}`);
  },
  logout: (userId: string) => {
    console.log(`User logged out: ${userId}`);
  },
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

You've successfully implemented a complete JWT authentication system in Next.js with the App Router. This implementation includes user registration, login, logout, token refresh, protected routes, and middleware protection.

The stateless nature of JWT makes it perfect for Next.js applications, especially when deploying to serverless or edge environments. Remember to follow security best practices, use HTTPS in production, and keep your tokens short-lived.

Have questions about Next.js JWT authentication? Drop them in the comments below!

Found this helpful? Give it a like and follow for more Next.js tutorials!

Top comments (0)