This is a note to self on implementing authentication, using Next.js, Auth.js, and MongoDB, allowing role-based access control, and beginner's notes about JWT as a bonus topic.
Authentication and RBAC
Tools
- Next.js
- Here, we use Server Actions to handle authentication.
-
Auth.js
- Here, we use the login method of credentials (email and password).
- MongoDB
-
bcrypt
- This is for password hashing.
- We should install TypeScript definitions for bcrypt as well by
npm install --save-dev @types/bcrypt
.
Directory Structure
project
└─ src
└─ app
│ └─ page.tsx <- renders LoginForm.tsx
└─ components
│ └─ loginForm.tsx
└─ lib
│ └─ actions.ts
│ └─ db.ts
└─ types
│ └─ next-auth.d.ts
└─ auth.config.ts
└─ auth.ts
└─ middleware.ts
Code
Omitting the styling purpose codes.
- login page with login form
import LoginForm from "@/components/loginForm";
import { Suspense } from "react";
export default function Home() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
"use client";
import { Button } from "@/components/ui/button";
import { useActionState } from "react";
import { authenticate } from "@/lib/actions";
import { useSearchParams } from "next/navigation";
export default function LoginForm() {
const searchParams = useSearchParams();
const callbackUrl =
searchParams.get("callbackUrl") || "/patient-status-display";
const [errorMessage, formAction, isPending] = useActionState(
authenticate,
undefined
);
return (
<form action={formAction} className="space-y-3">
<div className="w-full">
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
</div>
</div>
<input type="hidden" name="redirectTo" value={callbackUrl} />
<Button aria-disabled={isPending}>
Log in
</Button>
<div
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
)}
</div>
</form>
);
}
There are three inputs sent as formData here: email, password, and redirectTo, which is hidden from the UI. RedirectTo is automatically set considering the search params of the page URL, allowing the user to go back to the page that they were on after logging in.
- server actions
// /lib/actions.ts
"use server";
import { signIn } from "@/auth";
import { AuthError } from "next-auth";
export async function authenticate(
prevState: string | undefined,
formData: FormData
) {
console.log("from actions.ts - ", formData);
try {
await signIn("credentials", formData);
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
console.error("Invalid credentials");
return "Invalid credentials.";
default:
console.error("An unexpected error occurred:", error);
return "Something went wrong.";
}
}
throw error;
}
}
The outcome of logging formData to the console is like this:
FormData {
'$ACTION_REF_2': '',
'$ACTION_2:0': '{"id":"idconsistofrandomcharsandnumbers","bound":"$@1"}',
'$ACTION_2:1': '["$undefined"]',
'$ACTION_KEY': 'keyconsistofacharandnumbers',
email: 'admin@email.com',
password: '123456',
redirectTo: '/patient-status-display'
}
This Server Actions function authenticate()
triggers the Credentials provider's authorize()
function in auth.ts
below.
- middleware-purpose code (middleware.ts and auth.ts)
-1. middleware.ts
// /middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export default NextAuth(authConfig).auth;
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
-2. auth.ts (This is necessary separately from middleware.ts
because bcrypt
relies on Node.js APIs not available in Next.js Middleware.)
// /auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { authConfig } from "./auth.config";
// MongoDB and Auth.js connection purpose things
import { MongoDBAdapter } from "@auth/mongodb-adapter";
import client from "./lib/db";
import { z } from "zod";
import { User } from "@/types/db";
import bcrypt from "bcrypt";
// helper function to get a user from db and return a user object.
async function getUser(email: string): Promise<User | undefined> {
try {
// specify the database name and table name
const db = client.db("Surgery-Status");
const users = db.collection("User");
const user = await users.findOne({ email });
console.log("Raw user result:", user);
if (!user) {
return undefined;
}
return {
id: user._id.toString(),
username: user.username,
email: user.email,
password: user.password,
role: user.role,
} as User;
} catch (error) {
console.error("Failed to fetch user:", error);
throw new Error("Failed to fetch user.");
}
}
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
session: {
strategy: "jwt",
},
// adapter: MongoDBAdapter(client), //edit: this is for session strategy of "database", not "jwt"!
providers: [
Credentials({
async authorize(credentials) {
console.log("from auth.ts - credentials: ", credentials);
// validation by Zod
const parsedCredentials = z
.object({ email: z.email(), password: z.string().min(6) })
.safeParse(credentials);
console.log("from auth.ts - parsedCredentials: ", parsedCredentials);
if (!parsedCredentials.success) {
console.log("from auth.ts - Invalid credentials");
return null;
}
// call getUser helper function and retrieve the user from db.
const { email, password } = parsedCredentials.data;
const user = await getUser(email);
console.log("from auth.ts - user: ", user);
if (!user) return null;
// check if the password input matches the user's password using bcrypt.
const passwordsMatch = await bcrypt.compare(password, user.password);
if (!passwordsMatch) return null;
return user;
},
}),
],
});
Outcome of logging credentials:
{
'$ACTION_REF_2': '',
'$ACTION_2:0': '{"id":"608d2e3498577677e1980778f144b62db597e78013","bound":"$@1"}',
'$ACTION_2:1': '["$undefined"]',
'$ACTION_KEY': 'k3886862053',
email: 'admin@email.com',
password: '123456',
callbackUrl: '/patient-status-display'
}*the same object as formData
Outcome of logging parsedCredentials:
{
success: true,
data: { email: 'admin@email.com', password: '123456' }
}
Outcome of logging raw user result in getUser:
{
_id: new ObjectId('687a1ecc6e0c2baed2f73a00'),
username: 'Admin',
email: 'admin@email.com',
password: '$2b$10$Aio9fK46uuXn5nMidbKhv.uk.Izp3V2ceQltitKWJiisbr4VpFrgq',
role: 'admin'
}
Returned valid user from this authorize()
function if everything is successful:
{
id: '687a1ecc6e0c2baed2f73a00',
username: 'Admin',
email: 'admin@email.com',
password: '$2b$10$Aio9fK46uuXn5nMidbKhv.uk.Izp3V2ceQltitKWJiisbr4VpFrgq',
role: 'admin'
}
*This returned user object is just returned internally to Auth.js.
-> Then, Auth.js creates a session (JWT), sets a cookie, and redirects the user.
- MongoDB client code
- taken from Auth.js docs
// /lib/db.ts
// This approach is taken from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb
import { MongoClient, ServerApiVersion } from "mongodb"
if (!process.env.MONGODB_URI) {
throw new Error('Invalid/Missing environment variable: "MONGODB_URI"')
}
const uri = process.env.MONGODB_URI
const options = {
serverApi: {
version: ServerApiVersion.v1,
strict: true,
deprecationErrors: true,
},
}
let client: MongoClient
if (process.env.NODE_ENV === "development") {
// In development mode, use a global variable so that the value
// is preserved across module reloads caused by HMR (Hot Module Replacement).
const globalWithMongo = global as typeof globalThis & {
_mongoClient?: MongoClient
}
if (!globalWithMongo._mongoClient) {
globalWithMongo._mongoClient = new MongoClient(uri, options)
}
client = globalWithMongo._mongoClient
} else {
// In production mode, it's best to not use a global variable.
client = new MongoClient(uri, options)
}
// Export a module-scoped MongoClient. By doing this in a
// separate module, the client can be shared across functions.
export default client
- auth.config.ts (RBAC is set here.)
// /auth.config.ts
import type { NextAuthConfig } from "next-auth";
export const authConfig = {
pages: {
signIn: "/",
},
callbacks: {
jwt({ token, user }) {
console.log(token, user) //*1
if (user) {
token.role = user.role;
}
return token;
},
session({ session, token }) {
console.log(sessioin, token) //*2
if (token && session.user) {
session.user.role = token.role as string;
// if we omit this^, the `role` field won't be available in the session on the browser.
}
return session;
},
authorized({ auth, request: { nextUrl } }) {
console.log("auth from authorized callback - ", auth);
const isLoggedIn = !!auth?.user;
const userRole = auth?.user?.role;
// check the route that the user is trying to access.
const isOnStatusDisplay = nextUrl.pathname.startsWith(
"/patient-status-display"
);
const isOnStatusUpdate = nextUrl.pathname.startsWith(
"/patient-status-update"
);
const isOnPatientInfo = nextUrl.pathname.startsWith(
"/patient-information"
);
// redirect unauthenticated users to login page.
// patient-status-display page is available for unauthenticated users, so it's not included in the condition.
if (!isLoggedIn && (isOnStatusUpdate || isOnPatientInfo)) {
return false;
}
// if already authenticated, do role-based access control.
if (isLoggedIn) {
// admin can access all pages
if (userRole === "admin") {
return true;
}
// member cannot access patient-information. Redirect to patient-status-display in which case.
if (userRole === "member" && isOnPatientInfo) {
return Response.redirect(new URL("/patient-status-display", nextUrl));
}
// member cannot access the unavailable pages. Redirect to patient-status-display in which case.
if (!isOnStatusDisplay && !isOnStatusUpdate && !isOnPatientInfo) {
return Response.redirect(new URL("/patient-status-display", nextUrl));
}
// member can access the pages which is outside the conditions above.
return true;
}
return true;
},
},
providers: [], // this is just to satisfy the specified structure.
} satisfies NextAuthConfig;
Pages option allows us to specify the route for custom sign-in, sign-out, and error pages. By setting this, the user will be redirected to our custom login page, rather than the NextAuth.js default page.
Callbacks option contains three functions.
Jwt function is necessary to take the custom fields (in this case, role
) from the user object (which is returned from DB) and store them in the JWT. This runs at login, and then the JWT is stored in the cookie on the browser.
Outcome of logging token and user (*1) (if a user is logged in):
token: {
email: 'admin@email.com',
sub: '687a1ecc6e0c2baed2f73a00',
role: 'admin',
iat: 1753258099,
exp: 1755850099,
jti: 'a82b44b4-c226-4216-bba5-63100f9e134e'
}
user: undefined
Session function is necessary to define what should be returned as a session from useSession()
(in the client) or getServerSession()
(in the server), or auth()
(for both client and server) (related part in Auth.ts docs). This runs when the client or the server asks for session data, copying the custom fields from the JWT in this case. (Here, we are talking about the case where the session strategy is JWT, not database.)
Outcome of logging session and token (*2) (if a user is logged in):
session: {
user: { name: undefined, email: 'admin@email.com', image: undefined },
expires: '2025-08-22T08:08:19.085Z'
}
token: {
email: 'admin@email.com',
sub: '687a1ecc6e0c2baed2f73a00',
role: 'admin',
iat: 1753258099,
exp: 1755850099,
jti: 'a82b44b4-c226-4216-bba5-63100f9e134e'
}
Authorized function is in charge of protecting the routes, depending on whether or not the user is authenticated, or which role the user has. The property auth
contains the user's session, and the request
property contains the incoming request.
Under the hood, the following steps are happening...
-1. This grabs the JWT stored in the cookie.
-2. Then, verifies and decodes it using the NEXTAUTH_SECRET.
(-3. Then, if necessary, runs jwt()
callback to reconstruct the final token.)
-4. Finally, converts that decoded token into an object, which is available in the function as a variable auth
.
Outcome of logging auth
(if a user is logged in):
{
user: { email: 'admin@email.com', role: 'admin' },
expires: '2025-08-19T11:38:31.810Z'
}
*The expiration date comes from the format of JWT; by default, JWT includesexp
claim (≒ field) when it's created.
- Update the NextAuth types to avoid type errors
// /types/next-auth.d.ts
import NextAuth from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
role: string;
} & DefaultSession["user"];
}
interface User {
id: string;
email: string;
role: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
role: string;
}
}
What I would like to explore further
In this case, I used Next.js Server Actions to access the database. Now I'm wondering how I can use the backend constructed with Node.js+Express.js to handle authentication with this stack, because I use the backend outside Next.js for the other APIs in this project for some reason. I also would like to know whether it's generally recommended to let the backend handle authentication as well in this case, or it's alright to use Server Actions just for authentication.
Side Note - JWT vs database as a session strategy
Session is the way we keep track of the logged-in user for authorization purposes.
There are two types of strategies how we manage sessions —database (session ID) and JWT.
-
database (session ID)
- This is stateful, meaning the server has the information necessary for authorization. The only thing transmitted is just session ID, and using it, the server needs to access the storage to extract the necessary info for authorization.
- When a user logs in, the server issues a session ID.
- Server stores the session ID along with the user's information in the session storage.
- Client stores the issued session ID in a cookie. Client sends this cookie every time it makes requests.
- Server accesses the stored user's info, verifies the authorization status based on the session ID contained in the cookie sent with the request.
-
JWT
- This is stateless, meaning the server doesn't have the information necessary for authorization. JWT itself contains it, so all the server has to do is just check it.
- When a user logs in, the server issues a JWT.
- Client stores the issued JWT in a cookie. Client adds this JWT to the header of the request when making it.
- Server verifies the JWT and its signature.
References
Top comments (0)