A Complete Guide to Authentication in Next.js 14

·

15 min read

A Complete Guide to Authentication in Next.js 14

In today's digital world, where security is a top priority, understanding and implementing effective authentication strategies is essential for web developers. This guide serves as your comprehensive guide to navigating the intricacies of authentication in Next.js 14, aiming to make your applications not only secure but also user-friendly.

We'll start by exploring the use of JSON Web Tokens (JWTs) to securely store credentials as cookies. This method not only enhances security but also improves the user experience by maintaining session states seamlessly. Next, we'll discuss the storage of a userData object as a cookie, a technique that allows easy access to public user information without compromising security.

Utilizing JSON Web Tokens (JWTs) in our Next.js 14 authentication system offers a significant performance benefit. The middleware in our application can decode these JWTs, which are stored as cookies in the user's browser. This process allows us to quickly verify user identities and access permissions without constantly querying the database. By relying on JWTs, we reduce the number of database requests, resulting in faster response times and a more efficient application. This approach not only improves performance but also enhances the user experience, making our Next.js application more responsive and scalable.

Middleware is the cornerstone of our authentication system, designed to protect routes that require authentication. By implementing this, you ensure that only authenticated users can access certain parts of your application, safeguarding sensitive information and user privacy.

In addition, we'll be integrating Cloudflare Turnstile to fortify our login form against brute force attacks. Cloudflare Turnstile is a security tool designed to detect and mitigate automated threats, including brute force attacks, which are common vulnerabilities in web forms. By deploying Turnstile, we add an extra layer of protection that operates seamlessly in the background, distinguishing between legitimate users and malicious bots.

Finally, we'll look at the importance of periodically checking the database to confirm that user accounts are still active. This step is crucial in maintaining the integrity of your user base and preventing unauthorized access.

NOTE: I created a boilerplate starter app that uses Sequelize as an ORM, complete with a seeder and docker-compose file. Link at the bottom.

Ok, let's get into the code!

Installing Dependencies

To implement our authentication strategy, we'll need a couple dependencies:

  • The jose library simplifies signing and decoding JWTs.

  • The react-turnstile library is a CloudFlare Turnstile component for React

If you don't already have a CloudFlare account, then I highly recommend creating one. They provide a myriad of free services, including Turnstile, a free, privacy-first, and, best of all, simple, CAPTCHA solution.

Getting your Turnstile site key and secret keys

Creating Our JWT Secret Key

Next, we need to create a secret key that only the server has access to. Typically, the key is 32-bytes long. To generate a securely random key, you can use the openssl command in any posix environment:

openssl rand -base64 32

If you're on Windows, you can use node to do the same:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

If you'd like to have a Unix-like environment but you're a Windows user (as am I), then I highly recommend you check out my previous article titled, How to Get a Unix-Like Terminal Environment in Windows and Visual Studio Code. Link is at the bottom.

NOTE: It's crucial that you keep this key private and only accessible to the server. You can use a dot env provider or keep it in a secure location. Be sure you set it in Next.js without the NEXT_PUBLIC prefix. If you accidentally leak the key to the client, then an attacker could use your key to sign bogus credentials with whatever role they like and gain full access to your platform! However, even if this attack occurs, we will still implement a fail-safe check against the database. Read on.

Our final .env should look like this:

TURNSTILE_SECRET_KEY="YOUR SECRET KEY"
NEXT_PUBLIC_TURNSTILE_SITE_KEY="YOUR PUBLIC SITE KEY"
JWT_SECRET="YOUR SECURELY GENERATED 32-BYTE KEY"

Creating some helper functions

First, we'll create some helper functions to make our lives easier:

import { jwtVerify, JWTPayload, decodeJwt } from 'jose';
import { cookies } from 'next/headers';
import authConfig from '@/config/authConfig';

import { I_UserPublic } from '@/models/User.types';

export function getJwtSecretKey() {
    const secret = process.env.JWT_SECRET;

    if (!secret) {
        throw new Error('JWT Secret key is not set');
    }

    const enc: Uint8Array = new TextEncoder().encode(secret);
    return enc;
}

export async function verifyJwtToken(token: string): Promise<JWTPayload | null> {
    try {
        const { payload } = await jwtVerify(token, getJwtSecretKey());

        return payload;
    } catch (error) {
        return null;
    }
}

export async function getJwt() {
    const cookieStore = cookies();
    const token = cookieStore.get('token');

    if (token) {
        try {
            const payload = await verifyJwtToken(token.value);
            if (payload) {
                const authPayload: AuthPayload = {
                    id: payload.id as string,
                    firstName: payload.firstName as string,
                    lastName: payload.lastName as string,
                    email: payload.email as string,
                    phone: payload.phone as string,
                    role: payload.role as string,
                    iat: payload.iat as number,
                    exp: payload.exp as number,
                    openIdSub: payload.openIdSub as string,
                };
                return authPayload;
            }
        } catch (error) {
            return null;
        }
    }
    return null;
}

export async function logout() {
    const cookieStore = cookies();
    const token = cookieStore.get('token');

    if (token) {
        try {
            cookieStore.delete('token');
        } catch (_) {}
    }

    const userData = cookieStore.get('userData');
    if (userData) {
        try {
            cookieStore.delete('userData');
            return true;
        } catch (_) {}
    }

    return null;
}

export function setUserDataCookie(userData: I_UserPublic) {
    const cookieStore = cookies();

    cookieStore.set({
        name: 'userData',
        value: JSON.stringify(userData),
        path: '/',
        maxAge: 86400, // 24 hours
        sameSite: 'strict',
    });
}

interface I_TurnstileResponse {

    success: boolean;

}

export default async function checkTurnstileToken(token: string) {
    const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';

    const formData = new FormData();
    formData.append('secret', process.env.TURNSTILE_SECRET_KEY || '');
    formData.append('response', token);

    try {
        const result = await fetch(url, {
            body: formData,
            method: 'POST',
        });

        const outcome = (await result.json()) as I_TurnstileResponse;
        if (outcome.success) {
            return true;
        }
    } catch (err) {
        log.error(err);
    }
    return false;
}

Here's the types for our userData and authPayload objects:

export interface I_User {
    id: string;
    email: string;
    phone: string;
    password: string;
    pin: string;
    firstName: string;
    lastName: string;
    avatar: string;
    role: T_UserRole;
    status: T_UserStatus;
    totpSecret: string;
    createdAt: Date;
    updatedAt: Date;
    deletedAt: Date;
    lastLogin: Date;
    lastSeen: Date;
}

export interface I_UserPublic extends Omit<I_User, 'password' | 'totpSecret'> {}

And the add this to your index.d.ts:

declare interface AuthPayload {
    id: string;
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
    role: string;
    iat: number;
    exp: number;
    openIdSub?: string;
}

The reason why we have two different objects is the authPayload object is what we receive and decode from the client. We then use the id from this to query our database and then authenticate against it and store it as a cookie. The userData object contains much more information (such as profile information) than the JWT does.

Creating our login API route

Now let's create a login endpoint for our login form:

// src/app/auth/login/route.ts

import { NextResponse, NextRequest } from 'next/server';
import { User } from '@/models/associations';
import { SignJWT } from 'jose';

import * as log from '@/lib/common/logger';

import { getJwtSecretKey, setUserDataCookie, checkTurnstileToken } from '@/lib/server/auth';

export interface I_ApiUserLoginRequest {
    login: string;
    password: string;
    tsToken: string;
    code?: string;
}

export interface I_ApiUserLoginResponse extends ApiResponse {}

export const dynamic = 'force-dynamic';

// Create a POST endpoint
export async function POST(request: NextRequest) {
    const isDev = process.env.NODE_ENV === 'development';
    const body = (await request.json()) as I_ApiUserLoginRequest;

    // trim all input values
    const { login, password, tsToken } = Object.fromEntries(
        Object.entries(body).map(([key, value]) => [key, value?.trim()]),
    ) as I_ApiUserLoginRequest;

    if (!login || !password) {
        const res: I_ApiUserLoginResponse = {
            success: false,
            message: 'Either login or password is missing',
        };

        return NextResponse.json(res, { status: 400 });
    }

    // We won't require Turnstile in development mode
    if (!isDev) {
        if (!tsToken) {
            const res: I_ApiUserLoginResponse = {
                success: false,
                message: 'Missing Turnstile token',
            };

            return NextResponse.json(res, { status: 400 });
        }

        const isTurnstileTokenValid = await checkTurnstileToken(tsToken);
        if (!isTurnstileTokenValid) {
            const res: I_ApiUserLoginResponse = {
                success: false,
                message: 'Invalid Turnstile token',
            };

            return NextResponse.json(res, { status: 400 });
        }
    }

    try {
        // Fetch our user from the database
        const user = await User.login(login, password);

        // Check if user is active
        if (user.status !== 'active') throw new Error('User account is not active');

        /** Check TFA status, reject login and send code */

        // Create and sign our JWT
        const token = await new SignJWT({
            id: user.id,
            firstName: user.firstName,
            lastName: user.lastName,
            email: user.email,
            phone: user.phone,
            role: user.role,
        })
            .setProtectedHeader({ alg: 'HS256' })
            .setIssuedAt()
            .setExpirationTime(`${User.jwtExpires}s`)
            .sign(getJwtSecretKey());

        // create our response object
        const res: I_ApiUserLoginResponse = {
            success: true,
        };

        const response = NextResponse.json(res);

        // Store our JWT as a secure, HTTP-only cookie
        response.cookies.set({
            name: 'token',
            value: token,
            path: '/', // Accessible site-wide
            maxAge: 86400, // 24-hours or whatever you like
            httpOnly: true, // This prevents scripts from accessing
            sameSite: 'strict', // This does not allow other sites to access
        });

        // Store public user data as a cookie
        const userData = user.exportPublic();
        setUserDataCookie(userData);

        return response;
    } catch (error: any) {
        log.error(error);

        const res: I_ApiUserLoginResponse = {
            success: false,
            message: error.message || 'Something went wrong',
        };

        return NextResponse.json(res, { status: 500 });
    }
}

I personally use sequelize as my ORM. I'm not going to go into detail on how to set that up, but here are the relevant methods from my implementation:

public static async getByLoginId(loginId: string) {
    const user = await User.findOne({
        where: {
            [Op.or]: [{ phone: loginId }, { email: loginId.toLowerCase() }],
        },
    });

    return user;
}

public verifyPassword(password: I_User['password']) {
    // You'll need to install the 'bcryptjs' package
    return compareSync(password, this.password);
}

public static async login(login: string, password: I_User['password']) {
    try {
        const user = await User.getByLoginId(login);

        if (!user) {
            throw new Error('User not found');
        }

        // Check user status
        if (user.status === 'inactive' || user.status === 'banned') {
            throw new Error('User is inactive or banned');
        }

        user.lastLogin = new Date();
        user.lastSeen = new Date();
        await user.save();

        const isPasswordValid = user.verifyPassword(password);

        if (!isPasswordValid) {
            throw new Error('Invalid password');
        }

        return user;
    } catch (error: any) {
        log.error(error);
        throw new Error('Invalid login or password');
    }
}

public exportPublic(): I_UserPublic {
    // Remove sensitive information
    const { password, totpSecret, ...user } = this.toJSON() as I_User;

    return user;
}

Protecting routes with middleware.ts

Now we need to create middleware.ts at the root of the src directory. This middleware will verify our JWT on every route change. We'll divide our app into protected and public routes.

import { NextRequest, NextResponse } from 'next/server';

import { verifyJwtToken } from './lib/server/auth';

// Add whatever paths you want to PROTECT here
const authRoutes = ['/app/*', '/account/*', '/api/*', '/admin/*'];

// Function to match the * wildcard character
function matchesWildcard(path: string, pattern: string): boolean {
    if (pattern.endsWith('/*')) {
        const basePattern = pattern.slice(0, -2);
        return path.startsWith(basePattern);
    }
    return path === pattern;
}

export async function middleware(request: NextRequest) {
    // Shortcut for our login path redirect
    // Note: you must use absolute URLs for middleware redirects
    const LOGIN = `${process.env.NEXT_PUBLIC_BASE_URL}/login?redirect=${
        request.nextUrl.pathname + request.nextUrl.search
    }`;

    if (authRoutes.some(pattern => matchesWildcard(request.nextUrl.pathname, pattern))) {
        const token = request.cookies.get('token');

        // For API routes, we want to return unauthorized instead of
        // redirecting to login
        if (request.nextUrl.pathname.startsWith('/api')) {
            if (!token) {
                const response: ApiResponse = {
                    success: false,
                    message: 'Unauthorized',
                };
                return NextResponse.json(response, { status: 401 });
            }
        }

        // If no token exists, redirect to login
        if (!token) {
            return NextResponse.redirect(LOGIN);
        }

        try {
            // Decode and verify JWT cookie
            const payload = await verifyJwtToken(token.value);

            if (!payload) {
                // Delete token
                request.cookies.delete('token');
                return NextResponse.redirect(LOGIN);
            }

            // If you have an admin role and path, secure it here
            if (request.nextUrl.pathname.startsWith('/admin')) {
                if (payload.role !== 'admin') {
                    return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/access-denied`);
                }
            }
        } catch (error) {
            // Delete token if authentication fails
            request.cookies.delete('token');
            return NextResponse.redirect(LOGIN);
        }
    }

    let redirectToApp = false;
    // Redirect login to app if already logged in
    if (request.nextUrl.pathname === '/login') {
        const token = request.cookies.get('token');

        if (token) {
            try {
                const payload = await verifyJwtToken(token.value);

                if (payload) {
                    redirectToApp = true;
                } else {
                    // Delete token
                    request.cookies.delete('token');
                }
            } catch (error) {
                // Delete token
                request.cookies.delete('token');
            }
        }
    }

    if (redirectToApp) {
        // Redirect to app dashboard
        return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/app`);
    } else {
        // Return the original response unaltered
        return NextResponse.next();
    }
}

IMPORTANT: Be sure to keep the login form and auth login route outside the protected directories or you'll create an infinite loop! I keep my login route in src/app/auth.

Creating a login form

For sake of brevity, I won't provide a detailed walk-through on how to create the login form. I'm assuming you already know how to do that. But here's how to implement react-turnstile in your form:

import Turnstile from 'react-turnstile';

export default function LoginPage() {
    const isDev = process.env.NODE_ENV === 'development';

    return(
        <div>
            {!isDev && !isLoading ? (
                <Turnstile
                    sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
                    className="mx-auto"
                    onVerify={(token: string) => {
                        setTsToken(token);
                    }}
                />
            ) : null}
        </div>
    )
}

NOTE: we conditionally render Turnstile only when the isLoading state is false. This causes Turnstile to refresh and generate a new token, say, if the user enters the wrong password. Tokens can only be used once.

Creating an app context

It's generally a good idea to create a client context for your app so authentication functions and user data can be accessible site-wide. For that, we'll use React's createContext API:

import React, { createContext, useContext, useState, useEffect, FunctionComponent } from 'react';

import { getUserData } from '@/lib/client/auth';
import { usePathname } from 'next/navigation';

import { I_UserPublic } from '@/models/User.types';
import { I_ApiAuthResponse } from '@/app/api/auth/route';

interface AppContextProps {
    isLoading: boolean;
    setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
    logoutCleanup: () => Promise<void>;
    userData: I_UserPublic | null;
    userDataLoaded: boolean;
    loadUserData: () => void;
}

export interface I_ModalProps {
    className: string;
}

const defaultModalProps: I_ModalProps = {
    className: 'bg-white',
};

const AppContext = createContext<AppContextProps | undefined>(undefined);

interface AppProviderProps {
    children: React.ReactNode;
}

const USERDATA_TTL = 60 * 5; // 5 minutes

export const AppProvider: FunctionComponent<AppProviderProps> = ({ children }) => {
    const pathname = usePathname();
    const [isLoading, setIsLoading] = useState<boolean>(false);
    const [userData, setUserData] = useState<I_UserPublic | null>(null);
    const [userDataLoaded, setUserDataLoaded] = useState<boolean>(false);
    const [userDataLastLoad, setUserDataLastLoad] = useState<Date>(new Date());

    const fetcher = async (url: string) => {
        const response = await fetch(url);
        const data = await response.json();
        return data.data;
    };

    const logoutCleanup = async () => {
        setUserData(null);
        setUserDataLoaded(false);
    };

    const loadUserData = () => {
        setUserDataLoaded(false);
        const userData = getUserData();
        setUserData(userData);
        setUserDataLoaded(true);
    };

    const loadUserDataFromServer = async () => {
        try {
            const response = await fetch('/api/auth');
            const data = (await response.json()) as I_ApiAuthResponse;
            const { success } = data;

            if (!success) {
                let message = 'Failed to load user data from server';
                if (data.message) message = data.message;
                console.error(message);
                return;
            }

            setUserDataLastLoad(new Date());
        } catch (_) {
            console.error('Failed to load user data from server');
        } finally {
            loadUserData();
        }
    };

    // Fires on first load
    useEffect(() => {
        loadUserDataFromServer();
    }, []);

    // Fires on page load
    useEffect(() => {
        const userData = getUserData();
        setUserData(userData);
        setUserDataLoaded(true);

        // Reload user data from server if USERDATA_TTL has expired
        if (userDataLastLoad) {
            const now = new Date();
            const diff = now.getTime() - userDataLastLoad.getTime();
            if (diff > USERDATA_TTL * 1000) {
                loadUserDataFromServer();
            }
        }
    }, [pathname]);

    return (
        <AppContext.Provider
            value={{
                isLoading,
                setIsLoading,
                logoutCleanup,
                userData,
                userDataLoaded,
                loadUserData,
            }}
        >
            {children}
        </AppContext.Provider>
    );
};

export const useApp = (): AppContextProps => {
    const context = useContext(AppContext);
    if (!context) {
        throw new Error('useApp must be used within AppProvider');
    }
    return context;
};

The key feature of this context is the loadUserDataFromServer function. This pings an endpoint that pulls the user from the database and checks that a.) the user still exists, and b.) the user still has access (e.g. not banned or inactive). If the user no longer has access, the API route deletes the token and userData cookies, causing the use to be logged out. We do this every 5 minutes to limit the load on the database.

Also note that we're doing this in a side effect asynchronously, so we don't create a blocking fetch request.

Here's the code for the /api/auth endpoint:

import { User } from '@/models/associations';
import { NextRequest } from 'next/server';

import { setUserDataCookie, logout } from '@/lib/server/auth';

import { I_UserPublic } from '@/models/User.types';

export interface I_ApiAuthResponse extends ApiResponse {
    user?: I_UserPublic;
}

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
    try {
        const user = await User.getAuthUserFromDb();
        if (!user) throw new Error('User not found');

        // Update last seen timesamp
        user.lastSeen = new Date();
        await user.save();

        const userData = user.exportPublic();
        const response: I_ApiAuthResponse = {
            success: true,
            user: userData,
        };

        // Refresh our userdata cookie in case information was changed elsewhere
        setUserDataCookie(userData);

        return new Response(JSON.stringify(response), {
            headers: {
                'content-type': 'application/json',
            },
        });
    } catch (err: any) {
        console.error(err);
        // Call logout in case of error, err on the side of caution
        logout();
        let message = 'Something went wrong';
        if (err.message) message = err.message;
        const response: I_ApiAuthResponse = {
            success: false,
            message,
        };
        return new Response(JSON.stringify(response), {
            status: 500,
            headers: {
                'content-type': 'application/json',
            },
        });
    }
}

And if you're curious, here's the relevant method from my sequelize model:

import { getJwt, logout } from '@/lib/server/auth';

public static async getAuthUserFromDb() {
    const jwt = await getJwt();

    if (!jwt) {
        return null;
    }

    const user = await User.findByPk(jwt.id);

    if (!user) {
        await logout();
        return null;
    }

    // Check if user is banned or inactive
    if (user && (user.status === 'banned' || user.status === 'inactive')) {
        await logout();
        return null;
    }

    return user;
}

Creating a logout endpoint

Logging out is as simple as deleting the cookie:

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

import { logout } from '@/lib/server/auth';

export async function GET() {
    await logout();

    const response: ApiResponse = {
        success: true,
        message: 'Logged out successfully',
    };

    return new Response(JSON.stringify(response), {
        headers: {
            'Content-Type': 'application/json',
        },
    });
}

Conclusion

We've covered the use of JWTs for efficient user authentication, storing user data in cookies for quick access, and the importance of custom middleware for route protection. Additionally, we've seen how integrating Cloudflare Turnstile can significantly enhance security by protecting our login forms from brute force attacks.

Remember, the journey to secure and efficient authentication is ongoing and ever-evolving. The strategies and tools we've discussed are at the forefront of current best practices, providing a strong foundation for your Next.js applications. However, the world of web security is dynamic, so staying updated with the latest trends and updates is crucial.

I hope this guide empowers you to implement these authentication techniques in your projects, ensuring they are not only secure but also user-friendly. With these tools and knowledge at your disposal, you're well-equipped to create applications that stand strong in the face of evolving digital threats while providing a seamless user experience. Happy coding!

Further Reading

Resources


Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.