Complete guide to authentication in modern web applications — JWT implementation, session management, RBAC, OAuth, and security best practices for Next.js production apps.
We chose custom JWT authentication over NextAuth/Auth.js for specific reasons:
| Requirement | NextAuth | Custom JWT |
|---|---|---|
| Edge-compatible | ❌ Partial | ✅ Full (Jose) |
| Custom token claims | ❌ Limited | ✅ Complete control |
| 4-role RBAC | ❌ Basic | ✅ Custom implementation |
| No dependency lock-in | ❌ | ✅ |
| Portal-specific tokens | ❌ | ✅ |
// lib/auth.ts
import { SignJWT, jwtVerify } from "jose";
import { hash, compare } from "bcrypt";
// actions/auth.actions.ts
"use server";
import { z } from "zod";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { generateToken, verifyPassword } from "@/lib/auth";
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function loginAction(
prevState: { error?: string; success?: string },
formData: FormData
) {
const validated = LoginSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!validated.success) {
return { error: validated.error.errors[0].message };
}
const user = await prisma.user.findUnique({
where: { email: validated.data.email, deletedAt: null },
});
if (!user || !(await verifyPassword(validated.data.password, user.password))) {
return { error: "Invalid email or password" };
}
const token = await generateToken({
id: user.id,
email: user.email,
role: user.role,
});
const cookieStore = await cookies();
cookieStore.set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60, // 7 days
path: "/",
});
return { success: "Login successful" };
}
Our platform has 4 distinct user roles:
const ROLE_ROUTES: Record<string, string[]> = {
SUPER_ADMIN: ["/admin/**"],
CLIENT: ["/portal/**"],
CUSTOMER: ["/account/**"],
AFFILIATE: ["/affiliate-portal/**"],
};
// In middleware
export async function middleware(request: NextRequest) {
const token = request.cookies.get("token")?.value;
const user = token ? await verifyToken(token) : null;
const { pathname } = request.nextUrl;
// Check route access
for (const [role, routes] of Object.entries(ROLE_ROUTES)) {
const isProtected = routes.some((r) => matchPath(pathname, r));
if (isProtected && user?.role !== role) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
return NextResponse.next();
}
deletedAt: null in all user queriesSensitive configuration is encrypted at rest:
// lib/encryption.ts
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
const ALGORITHM = "aes-256-gcm";
const KEY = Buffer.from(process.env.JWT_SECRET!, "hex").slice(0, 32);
export function encrypt(text: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([
cipher.update(text, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return `${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`;
}
export function decrypt(encryptedText: string): string {
const [ivHex, encHex, tagHex] = encryptedText.split(":");
const decipher = createDecipheriv(
ALGORITHM,
KEY,
Buffer.from(ivHex, "hex")
);
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
return decipher.update(Buffer.from(encHex, "hex")).toString("utf8") +
decipher.final("utf8");
}
Used for SMTP credentials, PayPal keys, and other secrets stored in the SiteConfig table.
Our Developer Portfolio & SaaS Platform implements all of these authentication patterns. Get custom JWT auth, 4-role RBAC, encrypted secrets, and middleware protection out of the box.
Related reads:
Follow on LinkedIn for security best practices.
Secure by default. Our Developer Portfolio SaaS features custom JWT auth, RBAC, and AES-256-GCM encryption — $299.
Get the latest articles, tutorials, and updates delivered straight to your inbox. No spam, unsubscribe at any time.