Backend & DevOps Blog

Real-world experiences with MongoDB, Docker, Kubernetes and more

Securing Next.js Applications with JWT Authentication

When our company decided to rebuild our legacy application using Next.js, one of the most critical aspects was implementing a secure authentication system. After evaluating several options, we chose JSON Web Tokens (JWT) for their flexibility and stateless nature. However, implementing JWT authentication in Next.js presented some unique challenges due to the hybrid nature of Next.js applications, which combine server-side rendering, API routes, and client-side functionality. Here's how we built a secure authentication system and the lessons we learned along the way.

Understanding JWT Authentication

Before diving into implementation details, it's important to understand how JWT authentication works:

  1. The user logs in with credentials (username/password)
  2. The server validates these credentials and issues a signed JWT
  3. The client stores this token (typically in cookies or localStorage)
  4. For subsequent requests, the client includes the token in the Authorization header
  5. The server validates the token's signature and extracts the user information

This approach is stateless, as the server doesn't need to store session information—all necessary data is encoded in the token itself.

JWT Structure in Our Implementation

Our JWT tokens were structured as follows:

// JWT Payload Structure
{
  "sub": "user-123", // Subject (user ID)
  "name": "John Doe", // User's name
  "role": "admin", // User's role
  "iat": 1643235780, // Issued at timestamp
  "exp": 1643267380  // Expiration timestamp (8 hours after issuance)
}

Setting Up JWT Authentication in Next.js

Let's look at our implementation, starting with the necessary dependencies:

npm install jsonwebtoken cookie js-cookie

1. Creating JWT Utilities

First, we created utility functions for generating and verifying JWTs:

// lib/jwt.js
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRY = '8h'; // 8 hours

/**
 * Generate a JWT token for a user
 */
export function generateToken(user) {
  if (!JWT_SECRET) {
    throw new Error('JWT_SECRET is not defined');
  }

  const payload = {
    sub: user.id,
    name: user.name,
    role: user.role,
    iat: Math.floor(Date.now() / 1000),
  };

  return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
}

/**
 * Verify a JWT token and return the decoded payload
 */
export function verifyToken(token) {
  if (!JWT_SECRET) {
    throw new Error('JWT_SECRET is not defined');
  }

  try {
    return jwt.verify(token, JWT_SECRET);
  } catch (error) {
    return null;
  }
}

/**
 * Get user information from a request object
 */
export function getUserFromRequest(req) {
  // Get token from header or cookies
  let token;
  
  if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
    // Get token from header
    token = req.headers.authorization.split(' ')[1];
  } else if (req.cookies && req.cookies.auth_token) {
    // Get token from cookies
    token = req.cookies.auth_token;
  }
  
  if (!token) {
    return null;
  }
  
  // Verify the token
  const decoded = verifyToken(token);
  return decoded;
}

2. Creating the Login Endpoint

Next, we implemented a login API endpoint:

// pages/api/auth/login.js
import { generateToken } from '@/lib/jwt';
import cookie from 'cookie';
import { validateUserCredentials } from '@/lib/auth';

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' });
  }

  try {
    const { email, password } = req.body;

    // Validate user credentials (implementation depends on your user management)
    const user = await validateUserCredentials(email, password);

    if (!user) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    // Generate a token
    const token = generateToken(user);

    // Set the token as an HTTP-only cookie
    res.setHeader('Set-Cookie', cookie.serialize('auth_token', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV !== 'development',
      sameSite: 'strict',
      maxAge: 8 * 60 * 60, // 8 hours
      path: '/',
    }));

    // Return user info (without sensitive data)
    res.status(200).json({
      id: user.id,
      name: user.name,
      email: user.email,
      role: user.role,
    });
  } catch (error) {
    console.error('Login error:', error);
    res.status(500).json({ message: 'Internal server error' });
  }
}

3. Protecting API Routes

To protect our API routes, we created a middleware function:

// lib/middleware.js
import { getUserFromRequest } from './jwt';

export function withAuth(handler, options = {}) {
  return async (req, res) => {
    // Get user from request
    const user = getUserFromRequest(req);

    // If no user is found and authentication is required
    if (!user && !options.optional) {
      return res.status(401).json({ message: 'Unauthorized' });
    }

    // If role is specified, check if user has the required role
    if (options.role && (!user || user.role !== options.role)) {
      return res.status(403).json({ message: 'Forbidden' });
    }

    // Attach user to request object
    req.user = user;

    // Call the handler
    return handler(req, res);
  };
}

// Usage example:
// export default withAuth(handler, { role: 'admin' })
// export default withAuth(handler, { optional: true })

Then we applied this middleware to protect our API routes:

// pages/api/users/profile.js
import { withAuth } from '@/lib/middleware';
import { getUserProfile } from '@/lib/user';

async function handler(req, res) {
  try {
    // The user object is available from the middleware
    const { sub: userId } = req.user;
    
    // Get the user profile
    const profile = await getUserProfile(userId);
    
    res.status(200).json(profile);
  } catch (error) {
    console.error('Profile error:', error);
    res.status(500).json({ message: 'Internal server error' });
  }
}

// Wrap the handler with the auth middleware
export default withAuth(handler);

// For admin-only endpoints:
// export default withAuth(handler, { role: 'admin' });

4. Client-Side Authentication

For the client side, we created a React context to manage authentication state:

// context/AuthContext.js
import { createContext, useState, useContext, useEffect } from 'react';
import { useRouter } from 'next/router';
import Cookies from 'js-cookie';

const AuthContext = createContext();

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const router = useRouter();

  useEffect(() => {
    // Check if user is logged in on initial load
    async function loadUserFromCookies() {
      try {
        const response = await fetch('/api/auth/me');
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
        }
      } catch (error) {
        console.error('Error loading user:', error);
      } finally {
        setLoading(false);
      }
    }

    loadUserFromCookies();
  }, []);

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

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Login failed');
      }

      const userData = await response.json();
      setUser(userData);
      
      // Store a client-side flag in a cookie
      // Note: This is just a flag, the actual token is in an HttpOnly cookie
      Cookies.set('logged_in', 'true', { expires: 1 }); // 1 day
      
      return userData;
    } catch (error) {
      console.error('Login error:', error);
      throw error;
    } finally {
      setLoading(false);
    }
  };

  const logout = async () => {
    try {
      setLoading(true);
      await fetch('/api/auth/logout', { method: 'POST' });
      
      // Remove the client-side flag
      Cookies.remove('logged_in');
      
      setUser(null);
      router.push('/login');
    } catch (error) {
      console.error('Logout error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <AuthContext.Provider value={{ isAuthenticated: !!user, user, login, logout, loading }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);

Then we used this context in our components:

// pages/login.js
import { useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/router';

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

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    
    try {
      await login(email, password);
      router.push('/dashboard');
    } catch (err) {
      setError(err.message || 'An error occurred during login');
    }
  };

  return (
    <div className="container mx-auto max-w-md py-12">
      <h1 className="text-2xl font-bold mb-4">Login</h1>
      
      {error && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
          {error}
        </div>
      )}
      
      <form onSubmit={handleSubmit}>
        <div className="mb-4">
          <label className="block mb-2">Email</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full p-2 border rounded"
            required
          />
        </div>
        
        <div className="mb-4">
          <label className="block mb-2">Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full p-2 border rounded"
            required
          />
        </div>
        
        <button
          type="submit"
          disabled={loading}
          className="w-full bg-blue-500 text-white py-2 rounded"
        >
          {loading ? 'Logging in...' : 'Login'}
        </button>
      </form>
    </div>
  );
}

5. Protecting Client-Side Routes

To protect pages that require authentication, we created a wrapper component:

// components/PrivateRoute.js
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '@/context/AuthContext';

export default function PrivateRoute({ children, role }) {
  const { isAuthenticated, user, loading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    // If authentication is complete and user is not authenticated, redirect to login
    if (!loading && !isAuthenticated) {
      router.push(`/login?redirect=${router.asPath}`);
    }
    
    // If role is specified, check if user has the required role
    if (!loading && isAuthenticated && role && user?.role !== role) {
      router.push('/unauthorized');
    }
  }, [isAuthenticated, loading, role, user, router]);

  // Show loading indicator while checking authentication
  if (loading || !isAuthenticated) {
    return <div>Loading...</div>;
  }
  
  // If role check fails, don't render anything (will redirect)
  if (role && user?.role !== role) {
    return <div>Unauthorized</div>;
  }

  // If authenticated and role check passes, render the children
  return children;
}

And used it to protect our pages:

// pages/dashboard.js
import PrivateRoute from '@/components/PrivateRoute';
import { useAuth } from '@/context/AuthContext';

function Dashboard() {
  const { user } = useAuth();

  return (
    <div className="container mx-auto py-12">
      <h1 className="text-2xl font-bold mb-4">Dashboard</h1>
      <p>Welcome back, {user.name}!</p>
      
      {/* Dashboard content */}
    </div>
  );
}

export default function DashboardPage() {
  return (
    <PrivateRoute>
      <Dashboard />
    </PrivateRoute>
  );
}

// For admin-only pages:
// export default function AdminPage() {
//   return (
//     <PrivateRoute role="admin">
//       <AdminDashboard />
//     </PrivateRoute>
//   );
// }

6. Server-Side Protection with getServerSideProps

For pages that need server-side rendering with authentication:

// pages/profile.js
import { verifyToken } from '@/lib/jwt';

export async function getServerSideProps(context) {
  // Get the token from cookies
  const { auth_token } = context.req.cookies;
  
  if (!auth_token) {
    // Redirect to login if no token is found
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }
  
  // Verify the token
  const user = verifyToken(auth_token);
  
  if (!user) {
    // Redirect to login if token is invalid
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }
  
  // Fetch user profile data
  try {
    const profile = await getUserProfile(user.sub);
    
    // Return the data as props
    return {
      props: {
        profile,
      },
    };
  } catch (error) {
    console.error('Error fetching profile:', error);
    
    return {
      props: {
        error: 'Failed to load profile',
      },
    };
  }
}

export default function ProfilePage({ profile, error }) {
  if (error) {
    return <div>Error: {error}</div>;
  }
  
  return (
    <div className="container mx-auto py-12">
      <h1 className="text-2xl font-bold mb-4">User Profile</h1>
      
      <div>
        <p><strong>Name:</strong> {profile.name}</p>
        <p><strong>Email:</strong> {profile.email}</p>
        <p><strong>Role:</strong> {profile.role}</p>
        {/* Other profile data */}
      </div>
    </div>
  );
}

Security Considerations and Best Practices

During our implementation, we prioritized security:

1. Secure Cookie Settings

We stored our JWT in HTTP-only cookies with secure settings:

  • httpOnly: true - Prevents JavaScript access to the cookie
  • secure: true (in production) - Only sends the cookie over HTTPS
  • sameSite: 'strict' - Prevents the cookie from being sent in cross-site requests

2. CSRF Protection

To protect against Cross-Site Request Forgery (CSRF) attacks, we implemented a CSRF token system for important actions:

// lib/csrf.js
import { randomBytes } from 'crypto';

// Generate a CSRF token
export function generateCsrfToken() {
  return randomBytes(32).toString('hex');
}

// Verify the CSRF token
export function verifyCsrfToken(token, storedToken) {
  return token === storedToken;
}

// Middleware for CSRF protection
export function withCsrf(handler) {
  return async (req, res) => {
    // Skip for GET, HEAD, OPTIONS
    if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
      return handler(req, res);
    }
    
    // Get the CSRF token from the headers
    const csrfToken = req.headers['x-csrf-token'];
    
    // Get the stored token from cookies
    const storedToken = req.cookies.csrf_token;
    
    // If tokens don't match, return 403
    if (!csrfToken || !storedToken || !verifyCsrfToken(csrfToken, storedToken)) {
      return res.status(403).json({ message: 'Invalid CSRF token' });
    }
    
    // Call the handler
    return handler(req, res);
  };
}

We applied this middleware to sensitive API routes:

// pages/api/users/update-password.js
import { withAuth } from '@/lib/middleware';
import { withCsrf } from '@/lib/csrf';
import { updateUserPassword } from '@/lib/user';

async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' });
  }
  
  try {
    const { currentPassword, newPassword } = req.body;
    const userId = req.user.sub;
    
    // Update password
    await updateUserPassword(userId, currentPassword, newPassword);
    
    res.status(200).json({ message: 'Password updated successfully' });
  } catch (error) {
    console.error('Update password error:', error);
    res.status(500).json({ message: error.message || 'Internal server error' });
  }
}

// Apply both auth and CSRF middleware
export default withAuth(withCsrf(handler));

3. Token Refresh Strategy

To balance security and user experience, we implemented token refresh:

// pages/api/auth/refresh.js
import { verifyToken, generateToken } from '@/lib/jwt';
import cookie from 'cookie';
import { getUserById } from '@/lib/user';

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' });
  }

  try {
    // Get the token from cookies
    const { auth_token } = req.cookies;
    
    if (!auth_token) {
      return res.status(401).json({ message: 'No token provided' });
    }
    
    // Verify the existing token
    const decoded = verifyToken(auth_token);
    
    // If token isn't valid, return 401
    if (!decoded) {
      return res.status(401).json({ message: 'Invalid token' });
    }
    
    // Get fresh user data
    const user = await getUserById(decoded.sub);
    
    if (!user) {
      return res.status(401).json({ message: 'User not found' });
    }
    
    // Generate a new token
    const newToken = generateToken(user);
    
    // Set the new token in cookies
    res.setHeader('Set-Cookie', cookie.serialize('auth_token', newToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV !== 'development',
      sameSite: 'strict',
      maxAge: 8 * 60 * 60, // 8 hours
      path: '/',
    }));
    
    res.status(200).json({
      id: user.id,
      name: user.name,
      email: user.email,
      role: user.role,
    });
  } catch (error) {
    console.error('Token refresh error:', error);
    res.status(500).json({ message: 'Internal server error' });
  }
}

We configured our client-side code to automatically refresh tokens as needed:

// lib/api.js
import { useAuth } from '@/context/AuthContext';

export function useApi() {
  const { logout } = useAuth();
  
  const fetchWithAuth = async (url, options = {}) => {
    try {
      // Make the API request
      const response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Content-Type': 'application/json',
        },
      });
      
      // If the response is 401 Unauthorized, attempt to refresh the token
      if (response.status === 401) {
        const refreshResponse = await fetch('/api/auth/refresh', {
          method: 'POST',
        });
        
        // If refresh is successful, retry the original request
        if (refreshResponse.ok) {
          return fetch(url, options);
        } else {
          // If refresh fails, log out the user
          logout();
          throw new Error('Session expired. Please log in again.');
        }
      }
      
      return response;
    } catch (error) {
      console.error('API error:', error);
      throw error;
    }
  };
  
  return { fetchWithAuth };
}

4. Additional Security Headers

We added security headers using Next.js config:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'X-XSS-Protection',
            value: '1; mode=block',
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin',
          },
          {
            key: 'Content-Security-Policy',
            value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
          },
        ],
      },
    ];
  },
};

Lessons Learned and Challenges

Implementing JWT authentication in Next.js taught us several important lessons:

  1. HTTP-only cookies are safer than localStorage for storing JWTs, as they protect against XSS attacks.
  2. Combining client and server authentication is complex in Next.js due to its hybrid nature, requiring careful coordination between API routes, getServerSideProps, and client components.
  3. Token refresh mechanisms are essential for balancing security (short-lived tokens) with user experience (not requiring frequent logins).
  4. CSRF protection is still necessary even with JWT, especially when using cookies.
  5. Environment variables must be handled carefully in Next.js, distinguishing between server-only secrets and client-accessible values.

Conclusion

Implementing JWT authentication in Next.js requires addressing the unique challenges of its hybrid rendering approach. By using HTTP-only cookies, implementing proper CSRF protection, and creating a robust token refresh strategy, we created a secure authentication system that provides a seamless user experience.

These patterns have served us well, providing a solid foundation for our Next.js application while maintaining strong security practices. By following similar approaches, you can create a secure, scalable authentication system for your own Next.js projects.