Blog

Why I Used JSON-Path RBAC and JWT Session Refresh in Auth.js v5 (Next.js 15)

Nested permission JSON, dot-path checks, legacy role fallbacks, and inactivity-aware JWT callbacks for a procurement CPMS.

Why I Used JSON-Path RBAC and JWT Session Refresh in Auth.js v5 (Next.js 15)
PMS2 min read2026-04-03
By Published Updated

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