Skip to content

Security: JWT trigger === "update" trusts client-supplied roles (privilege escalation) #436

@BigGillyStyle

Description

@BigGillyStyle

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

  • token.roles is never assigned from the client-supplied session payload on trigger === "update".
  • The admin-users-modal self-role-refresh still works (current user sees updated roles without re-login).
  • A test/manual check confirms a forged update({ roles: [admin] }) from a non-admin does not escalate.
  • The @typescript-eslint/no-unsafe-* disable in the callback is removed once the unsafe assignment is gone.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions