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.
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
- How to Get a Unix-Like Terminal Environment in Windows and Visual Studio Code
- Turnstile: CloudFlare's New reCaptcha Replacement in NextJS / React
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.
Top comments (2)
Very nice complete story. I love that you also show how to generate a good secret! The paragraph about
NEXT_PUBLIC
is a bit confusing though, but I can see what you meant from the .env code example you give later.Thank you for the feedback. I'll be sure to correct it. There's a type and it is incorrectly worded. Take care! :-)