Summary
The NextAuth JWT callback in packages/auth/src/config.ts copies client-supplied session.roles directly into the authorization token on the update trigger, with no server-side validation. Because the session payload on a trigger === "update" call originates from the client (useSession().update(...)), any authenticated user can set their own token.roles — including granting themselves admin on any org — without ever passing through an authorization check.
Originally surfaced by CodeRabbit on PR #432 and withdrawn there only because it was unrelated to that PR's diff (which merely widened an eslint-disable). The underlying issue predates #432 and is tracked here.
Severity: High — vertical privilege escalation. Authentication is required, but no special privileges are; the actor escalates from any logged-in account to admin.
Vulnerable code
packages/auth/src/config.ts, JWT callback:
if (trigger === "update" && session && "roles" in session) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
token.roles = session.roles;
}
token.roles is the sole source of authorization (it flows into session.roles via the session callback, and the API middleware authorizes off it — see apps/api/middleware/with-admin.ts / with-editor.ts).
Why session here is untrusted
In Auth.js / NextAuth v5, calling useSession().update(data) on the client issues a request to the server that invokes the jwt callback with trigger === "update" and data mapped to the session parameter. Per the Auth.js docs this data is client-controlled and must be validated before being written to the token. See https://authjs.dev/reference/core and the discussion at nextauthjs/next-auth#10938.
Exploit
Any authenticated user, from the browser console on an authenticated page:
// next-auth/react
await update({ roles: [{ orgId: 1, orgName: "Anytown", roleName: "admin" }] });
The JWT callback writes those roles into the token verbatim. Subsequent requests carry admin authorization. No server-side check gates this path.
Legitimate flow the fix must preserve
There is one real caller — apps/admin/src/app/_components/modal/admin-users-modal.tsx:172-174:
if (session?.id === data.id && roles.length > 0) {
await update({ ...session, roles });
}
This refreshes the current user's own roles in their token after an admin edits them via the server-authorized orpc.user.crupdate mutation. The intent ("re-sync my roles after they changed") is valid; the problem is the JWT callback trusts the client round-trip instead of re-reading the source of truth. A fix must keep self-role-refresh working.
Recommended fix
On trigger === "update", ignore session.roles and re-derive roles from the database (trusted source) keyed by the token's user id, rather than copying the client payload. Sketch:
if (trigger === "update" && token.id != null) {
// Re-read roles from the DB; never trust the client-supplied session payload.
token.roles = await getUserRolesById(token.id);
}
This keeps the admin-modal self-refresh flow working (the client just signals "re-sync"; the server fetches authoritative roles) while closing the injection vector. Roles should only ever enter the token from trusted server flows (signIn, where they come from user.roles, and this DB re-read path).
Acceptance criteria
References
Summary
The NextAuth JWT callback in
packages/auth/src/config.tscopies client-suppliedsession.rolesdirectly into the authorization token on theupdatetrigger, with no server-side validation. Because thesessionpayload on atrigger === "update"call originates from the client (useSession().update(...)), any authenticated user can set their owntoken.roles— including granting themselvesadminon any org — without ever passing through an authorization check.Severity: High — vertical privilege escalation. Authentication is required, but no special privileges are; the actor escalates from any logged-in account to
admin.Vulnerable code
packages/auth/src/config.ts, JWT callback:token.rolesis the sole source of authorization (it flows intosession.rolesvia thesessioncallback, and the API middleware authorizes off it — seeapps/api/middleware/with-admin.ts/with-editor.ts).Why
sessionhere is untrustedIn Auth.js / NextAuth v5, calling
useSession().update(data)on the client issues a request to the server that invokes thejwtcallback withtrigger === "update"anddatamapped to thesessionparameter. Per the Auth.js docs this data is client-controlled and must be validated before being written to the token. See https://authjs.dev/reference/core and the discussion at nextauthjs/next-auth#10938.Exploit
Any authenticated user, from the browser console on an authenticated page:
The JWT callback writes those roles into the token verbatim. Subsequent requests carry admin authorization. No server-side check gates this path.
Legitimate flow the fix must preserve
There is one real caller —
apps/admin/src/app/_components/modal/admin-users-modal.tsx:172-174:This refreshes the current user's own roles in their token after an admin edits them via the server-authorized
orpc.user.crupdatemutation. The intent ("re-sync my roles after they changed") is valid; the problem is the JWT callback trusts the client round-trip instead of re-reading the source of truth. A fix must keep self-role-refresh working.Recommended fix
On
trigger === "update", ignoresession.rolesand re-derive roles from the database (trusted source) keyed by the token's user id, rather than copying the client payload. Sketch:This keeps the admin-modal self-refresh flow working (the client just signals "re-sync"; the server fetches authoritative roles) while closing the injection vector. Roles should only ever enter the token from trusted server flows (
signIn, where they come fromuser.roles, and this DB re-read path).Acceptance criteria
token.rolesis never assigned from the client-suppliedsessionpayload ontrigger === "update".update({ roles: [admin] })from a non-admin does not escalate.@typescript-eslint/no-unsafe-*disable in the callback is removed once the unsafe assignment is gone.References
packages/auth/src/config.ts(JWT callback),apps/api/middleware/with-admin.ts,apps/api/middleware/with-editor.tsapps/admin/src/app/_components/modal/admin-users-modal.tsx