
Why I Used JSON-Path RBAC and JWT Session Refresh in Auth.js v5 (Next.js 15)
Direct answer (40 words): Nested JSON in Role.permissions maps cleanly to sidebar modules and CRUD without explosion of enums; dot-path hasPermission keeps checks readable; JWT callbacks refresh role data on update and invalidate sessions after 24h inactivity—aligning with procurement audit expectations.
Problem
Flat RBAC (ADMIN | BUYER) breaks when finance, approvers, and supplier managers need read on one module and write on another. Storing structured permissions per role and evaluating them with a stable API avoids duplicating logic in every server action.
Pattern: hasPermission + path navigation
function getByPath(obj: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((acc, key) => {
if (!acc || typeof acc !== "object") return undefined;
return (acc as Record<string, unknown>)[key];
}, obj);
}
export function hasPermission(
permissions: Permissions | null | undefined,
path: string,
action: PermissionAction
): boolean {
if (!permissions) return false;
const node = getByPath(permissions, path);
if (!node || typeof node !== "object") return false;
return Boolean((node as PermissionNode)[action]);
}
Pattern: JWT hydration and legacy fallback
On login, the user’s role JSON is loaded; on session update or refresh, Prisma re-fetches the role and merges permissions, falling back to legacy maps when JSON is missing:
if (dbUser) {
(token as any).role = dbUser.role?.name ?? "BUYER";
(token as any).roleId = dbUser.roleId;
(token as any).permissions =
(dbUser.role?.permissions as Permissions | undefined) ??
getPermissionsFromLegacyRole(dbUser.role?.name ?? null);
(token as any).departmentId = dbUser.departmentId ?? null;
(token as any).departmentIds = Array.isArray(dbUser.departmentIds)
? dbUser.departmentIds
: (dbUser.departmentIds as string[] | null) ?? null;
(token as any).phone = dbUser.phone ?? null;
(token as any).jobTitle = dbUser.jobTitle ?? null;
token.name = dbUser.name;
}
Inactivity guardrail
const nowSeconds = Math.floor(Date.now() / 1000);
const lastActivityAt = (token as any).lastActivityAt as number | undefined;
// Force re-login after 24h of inactivity.
if (!user && lastActivityAt && nowSeconds - lastActivityAt > INACTIVITY_TIMEOUT_SECONDS) {
return {} as any;
}
Takeaway
Combine structured permissions with session refresh so permission changes in the database propagate without forcing every user to log out on deploy—while still enforcing idle timeout for shared workstations.
Read the full case study: CPMS — Comprehensive Procurement Management System