DEV Community

Cover image for Complete Guide to JWT Authentication in Next.js 15: From Setup to Production
sizan mahmud0
sizan mahmud0

Posted on

Complete Guide to JWT Authentication in Next.js 15: From Setup to Production

Authentication is a critical component of modern web applications. This comprehensive guide walks you through implementing secure JWT (JSON Web Token) authentication in Next.js, covering everything from basic setup to production-ready patterns.

Why JWT Authentication?

JWT authentication has become the standard for modern web applications because it's:

  • Stateless: No need to store sessions on the server
  • Scalable: Works seamlessly across multiple servers
  • Portable: Can be used across different platforms and domains
  • Secure: Cryptographically signed to prevent tampering
  • Self-contained: Carries user information in the token itself

Understanding JWT Structure

A JWT consists of three parts separated by dots:

header.payload.signature
Enter fullscreen mode Exit fullscreen mode
  • Header: Contains token type and signing algorithm
  • Payload: Contains user data (claims) like user ID, email, role
  • Signature: Ensures the token hasn't been tampered with

Setting Up Your Next.js Project

First, create a new Next.js project and install required dependencies:

npx create-next-app@latest jwt-auth-app
cd jwt-auth-app
npm install jose bcryptjs
npm install -D @types/bcryptjs
Enter fullscreen mode Exit fullscreen mode

jose: Modern JWT library for signing and verifying tokens
bcryptjs: For hashing passwords securely

Project Structure

src/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”‚   β”œβ”€β”€ login/route.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ register/route.ts
β”‚   β”‚   β”‚   └── logout/route.ts
β”‚   β”‚   └── protected/route.ts
β”‚   β”œβ”€β”€ login/page.tsx
β”‚   β”œβ”€β”€ dashboard/page.tsx
β”‚   └── layout.tsx
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ auth.ts
β”‚   └── db.ts
└── middleware.ts
Enter fullscreen mode Exit fullscreen mode

Creating Authentication Utilities

Create src/lib/auth.ts for JWT operations:

import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key-min-32-chars'
);

export async function createToken(payload: any) {
  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch (error) {
    return null;
  }
}

export async function getSession() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token')?.value;

  if (!token) return null;

  return await verifyToken(token);
}
Enter fullscreen mode Exit fullscreen mode

Building the Registration API

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

import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { createToken } from '@/lib/auth';

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

    // Validate input
    if (!email || !password || !name) {
      return NextResponse.json(
        { error: 'Missing required fields' },
        { status: 400 }
      );
    }

    // Check if user exists (replace with your DB logic)
    // const existingUser = await db.user.findUnique({ where: { email } });
    // if (existingUser) {
    //   return NextResponse.json(
    //     { error: 'User already exists' },
    //     { status: 409 }
    //   );
    // }

    // Hash password
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create user in database (pseudo-code)
    const user = {
      id: Date.now().toString(),
      email,
      name,
      password: hashedPassword,
    };
    // await db.user.create({ data: user });

    // Create JWT token
    const token = await createToken({
      userId: user.id,
      email: user.email,
      name: user.name,
    });

    // Set HTTP-only cookie
    const response = NextResponse.json(
      { 
        message: 'Registration successful',
        user: { id: user.id, email: user.email, name: user.name }
      },
      { status: 201 }
    );

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

    return response;
  } catch (error) {
    return NextResponse.json(
      { error: 'Registration failed' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Building the Login API

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

import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { createToken } from '@/lib/auth';

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

    if (!email || !password) {
      return NextResponse.json(
        { error: 'Email and password required' },
        { status: 400 }
      );
    }

    // Find user in database (pseudo-code)
    // const user = await db.user.findUnique({ where: { email } });
    const user = {
      id: '1',
      email: 'user@example.com',
      name: 'John Doe',
      password: await bcrypt.hash('password123', 10),
    };

    if (!user) {
      return NextResponse.json(
        { error: 'Invalid credentials' },
        { status: 401 }
      );
    }

    // Verify password
    const isValidPassword = await bcrypt.compare(password, user.password);

    if (!isValidPassword) {
      return NextResponse.json(
        { error: 'Invalid credentials' },
        { status: 401 }
      );
    }

    // Create JWT token
    const token = await createToken({
      userId: user.id,
      email: user.email,
      name: user.name,
    });

    // Set cookie and return response
    const response = NextResponse.json({
      message: 'Login successful',
      user: { id: user.id, email: user.email, name: user.name }
    });

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

    return response;
  } catch (error) {
    return NextResponse.json(
      { error: 'Login failed' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementing Middleware Protection

Create src/middleware.ts to protect routes:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from '@/lib/auth';

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

  // Protected routes
  const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');

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

    const payload = await verifyToken(token);

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

  // Redirect logged-in users away from auth pages
  if (token && request.nextUrl.pathname === '/login') {
    const payload = await verifyToken(token);
    if (payload) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return NextResponse.next();
}

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

Creating Login Page

Create src/app/login/page.tsx:

'use client';

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

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

  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({ email, password }),
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || 'Login failed');
      }

      router.push('/dashboard');
      router.refresh();
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
        <h2 className="text-3xl font-bold text-center">Sign in</h2>

        {error && (
          <div className="bg-red-50 text-red-500 p-3 rounded">
            {error}
          </div>
        )}

        <form onSubmit={handleSubmit} className="space-y-6">
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md disabled:opacity-50"
          >
            {loading ? 'Signing in...' : 'Sign in'}
          </button>
        </form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Creating Protected Dashboard

Create src/app/dashboard/page.tsx:

import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';

export default async function DashboardPage() {
  const session = await getSession();

  if (!session) {
    redirect('/login');
  }

  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold mb-4">Dashboard</h1>
        <div className="bg-white p-6 rounded-lg shadow">
          <p className="text-lg">Welcome, {session.name}!</p>
          <p className="text-gray-600">Email: {session.email}</p>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Implementing Logout

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

import { NextResponse } from 'next/server';

export async function POST() {
  const response = NextResponse.json({
    message: 'Logged out successfully'
  });

  response.cookies.delete('token');

  return response;
}
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

1. Environment Variables

Store sensitive data in .env.local:

JWT_SECRET=your-super-secret-key-minimum-32-characters-long
DATABASE_URL=your-database-connection-string
Enter fullscreen mode Exit fullscreen mode

2. Token Storage

  • Use HTTP-only cookies to prevent XSS attacks
  • Set secure flag in production
  • Use sameSite to prevent CSRF attacks

3. Password Security

  • Always hash passwords with bcrypt (minimum 10 rounds)
  • Never log or expose passwords
  • Implement password strength requirements

4. Token Expiration

  • Set reasonable expiration times (7 days recommended)
  • Implement refresh token mechanism for better UX
  • Clear expired tokens promptly

5. Rate Limiting

Implement rate limiting to prevent brute force attacks:

// Add to your API routes
const rateLimit = new Map();

function checkRateLimit(identifier: string) {
  const now = Date.now();
  const attempts = rateLimit.get(identifier) || [];
  const recentAttempts = attempts.filter((time: number) => now - time < 60000);

  if (recentAttempts.length >= 5) {
    return false;
  }

  rateLimit.set(identifier, [...recentAttempts, now]);
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Testing Your Authentication

Test your implementation:

  1. Registration: POST to /api/auth/register with email, password, name
  2. Login: POST to /api/auth/login with credentials
  3. Access Protected Route: Visit /dashboard (should redirect to login if not authenticated)
  4. Logout: POST to /api/auth/logout

Common Pitfalls to Avoid

  1. Storing JWT in localStorage: Vulnerable to XSS attacks
  2. Weak JWT secrets: Use at least 32 random characters
  3. No token expiration: Tokens should expire
  4. Exposing sensitive data in tokens: Keep payloads minimal
  5. Not validating input: Always validate and sanitize user input

Advanced Features to Consider

  • Refresh tokens for seamless re-authentication
  • Email verification before allowing login
  • Two-factor authentication for enhanced security
  • Password reset functionality
  • Social authentication (Google, GitHub, etc.)
  • Role-based access control (RBAC)

Conclusion

JWT authentication in Next.js provides a robust, scalable solution for securing your applications. By following these patterns and security best practices, you can build production-ready authentication systems that protect user data while providing excellent user experience.

Remember to always keep security at the forefront, regularly update dependencies, and stay informed about the latest security vulnerabilities and best practices in web authentication.

Top comments (0)