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

Related reading

Why I Used Prisma Client Extensions for CRUD Notifications (MSIS Perspective)

Cross-cutting notification emission on create/update/delete without breaking mutations, plus permission-aware recipients in CPMS.

Continue reading

How I Implemented Secure S3 Presigned URLs and Server Uploads for Procurement Documents

S3-compatible uploads with sanitized keys, server-side PutObject, and presigned GET for invoices and supplier documents in a Next.js App Router CPMS.

Continue reading

How I Implemented Department-Scoped Approval Workflows for Requisitions

Amount thresholds, manager vs finance gates, and department matching in a pure TypeScript workflow engine for CPMS.

Continue reading