Server-side internalAdapter.updateUser blocked by input: false fields #2697

Closed
opened 2026-03-13 10:13:50 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @0-Sandy on GitHub (Jan 13, 2026).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Define a custom user field (ex: role) with input: false so users cannot edit it:
user: {
  additionalFields: {
    role: {
      type: "string",
      defaultValue: "student",
      validator: { input: z.string() },
      input: false, // users cannot edit this
    },
  },
}
  1. Attempt to update the field server-side using internalAdapter.updateUser:

Example in a plugin:

await ctx.context.internalAdapter.updateUser(userId, {
  role: "admin", // fails
});
  1. Observe the error:

APIError: "role is not allowed to be set"

Current vs. Expected behavior

  • Current: Even server-side code is blocked from updating the field because input: false.
  • Expected: Fields marked with input: false should still be editable programmatically through internalAdapter.updateUser for plugins or admin logic, while preventing client-side edits.

What version of Better Auth are you using?

1.4.11

System info

{
  "system": {
    "platform": "win32",
    "arch": "x64",
    "version": "Windows 11 Pro",
    "release": "10.0.26100",
    "cpuCount": 16,
    "cpuModel": "AMD Ryzen 9 9950X3D",
    "totalMemory": "31.87 GB",
    "freeMemory": "18.24 GB"
  },
  "node": {
    "version": "v22.17.0",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.28.0"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "15.5.7"
    },
    {
      "name": "react",
      "version": "19.2.1"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.16.3"
    },
    {
      "name": "drizzle",
      "version": "^0.44.7"
    }
  ],
  "betterAuth": {
    "version": "^1.4.11",
    "config": null
  }
}

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth/minimal";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "~/drizzle/db";
import { env } from "~/env";
import {
  sendAccountDeletionEmail,
  sendChangeEmailEmail,
  sendPasswordCreateEmail,
  sendPasswordResetEmail,
  sendVerificationEmail,
  sendWelcomeEmail,
  sendInvitationEmail,
} from "../mail";
import { createPassword } from "./plugin/createPassword";
import { APIError } from "better-auth";
import {
  admin as adminPlugin,
  createAuthMiddleware,
  twoFactor,
} from "better-auth/plugins";
import { ac, student, admin, teacher, owner } from "./permissions";
import { SIGNIN_ERROR_URL } from "@/routes";
import { invite } from "./plugin/invite";
import { RoleEnum, RoleHierarchy, type RoleType } from "~/schemas";

export const auth = betterAuth({
  appName: "Test",
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url }) => {
      void sendPasswordResetEmail(user.name, user.email, url);
    },
    maxPasswordLength: 512,
    minPasswordLength: 8,
  },
  emailVerification: {
    autoSignInAfterVerification: true,
    sendOnSignUp: true,
    sendVerificationEmail: async ({ user, url }) => {
      void sendVerificationEmail(user.name, user.email, url);
    },
  },
  socialProviders: {
    google: {
      prompt: "select_account",
      clientId: env.BETTER_AUTH_GOOGLE_ID,
      clientSecret: env.BETTER_AUTH_GOOGLE_SECRET,
    },
  },
  session: {
    cookieCache: {
      enabled: true,
      maxAge: 60 * 5, // 5 minutes
    },
  },
  user: {
    additionalFields: {
      balance: { type: "number", defaultValue: 0 },
      role: {
        type: "string",
        defaultValue: "student",
        validator: { input: RoleEnum },
        input: false,
      },
    },
    changeEmail: {
      enabled: true,
      sendChangeEmailConfirmation: async ({ user, url, newEmail }) => {
        void sendChangeEmailEmail(user.name, user.email, newEmail, url);
      },
    },
    deleteUser: {
      enabled: true,
      sendDeleteAccountVerification: async ({ user, url }) => {
        void sendAccountDeletionEmail(user.name, user.email, url);
      },
    },
  },
  plugins: [
    nextCookies(),
    createPassword({
      sendCreatePassword: async ({ user, url }) => {
        void sendPasswordCreateEmail(user.name, user.email, url);
      },
    }),
    invite({
      defaultRoleForSignupWithoutInvite: "student",
      defaultMaxUses: 1,
      defaultRedirectTo: "/auth/sign-up",
      defaultTokenType: "token",
      canCreateInvite: (inviteUser, inviterUser) => {
        if (!inviteUser.role || !inviterUser.role) return false;

        return (
          RoleHierarchy[inviterUser.role as RoleType] >=
          RoleHierarchy[inviteUser.role]
        );
      },
      sendUserInvitation: async ({ email, role, url }) => {
        void sendInvitationEmail(role, email, url);
      },
    }),
    twoFactor(),
    adminPlugin({
      ac,
      roles: {
        student,
        teacher,
        admin,
        owner,
      },
      adminRoles: "owner",
      defaultRole: "student",
    }),
  ],
  advanced: {
    cookiePrefix: "recursos",
  },
  database: drizzleAdapter(db, {
    provider: "pg",
  }),
  hooks: {
    after: createAuthMiddleware(async (ctx) => {
      if (ctx.path.startsWith("/sign-up")) {
        const user = ctx.context.newSession?.user ?? {
          name: ctx.body.name,
          email: ctx.body.email,
        };
        if (user) {
          await sendWelcomeEmail(user.name, user.email);
        }
      }
    }),
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path === "/error") {
        console.log(
          "redirecting to error url",
          SIGNIN_ERROR_URL + "?" + ctx.query,
        );
        const queryString = new URLSearchParams(ctx.query).toString();
        throw ctx.redirect(SIGNIN_ERROR_URL + "?" + queryString);
      }
      if (ctx.path !== "/sign-up/email") {
        return ctx;
      }
      if (!ctx.body?.email.endsWith("@test.com")) {
        throw new APIError("BAD_REQUEST", {
          message: "Email must end with @test.com",
        });
      }
    }),
  },
});

Additional context

No response

Originally created by @0-Sandy on GitHub (Jan 13, 2026). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Define a custom user field (ex: role) with `input: false` so users cannot edit it: ```ts user: { additionalFields: { role: { type: "string", defaultValue: "student", validator: { input: z.string() }, input: false, // users cannot edit this }, }, } ``` 2. Attempt to update the field server-side using `internalAdapter.updateUser`: Example in a plugin: ```ts await ctx.context.internalAdapter.updateUser(userId, { role: "admin", // fails }); ``` 3. Observe the error: `APIError: "role is not allowed to be set"` ### Current vs. Expected behavior - **Current:** Even server-side code is blocked from updating the field because `input: false`. - **Expected:** Fields marked with `input: false` should still be editable programmatically through `internalAdapter.updateUser` for plugins or admin logic, while preventing client-side edits. ### What version of Better Auth are you using? 1.4.11 ### System info ```bash { "system": { "platform": "win32", "arch": "x64", "version": "Windows 11 Pro", "release": "10.0.26100", "cpuCount": 16, "cpuModel": "AMD Ryzen 9 9950X3D", "totalMemory": "31.87 GB", "freeMemory": "18.24 GB" }, "node": { "version": "v22.17.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.28.0" }, "frameworks": [ { "name": "next", "version": "15.5.7" }, { "name": "react", "version": "19.2.1" } ], "databases": [ { "name": "pg", "version": "^8.16.3" }, { "name": "drizzle", "version": "^0.44.7" } ], "betterAuth": { "version": "^1.4.11", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth/minimal"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { db } from "~/drizzle/db"; import { env } from "~/env"; import { sendAccountDeletionEmail, sendChangeEmailEmail, sendPasswordCreateEmail, sendPasswordResetEmail, sendVerificationEmail, sendWelcomeEmail, sendInvitationEmail, } from "../mail"; import { createPassword } from "./plugin/createPassword"; import { APIError } from "better-auth"; import { admin as adminPlugin, createAuthMiddleware, twoFactor, } from "better-auth/plugins"; import { ac, student, admin, teacher, owner } from "./permissions"; import { SIGNIN_ERROR_URL } from "@/routes"; import { invite } from "./plugin/invite"; import { RoleEnum, RoleHierarchy, type RoleType } from "~/schemas"; export const auth = betterAuth({ appName: "Test", emailAndPassword: { enabled: true, requireEmailVerification: true, sendResetPassword: async ({ user, url }) => { void sendPasswordResetEmail(user.name, user.email, url); }, maxPasswordLength: 512, minPasswordLength: 8, }, emailVerification: { autoSignInAfterVerification: true, sendOnSignUp: true, sendVerificationEmail: async ({ user, url }) => { void sendVerificationEmail(user.name, user.email, url); }, }, socialProviders: { google: { prompt: "select_account", clientId: env.BETTER_AUTH_GOOGLE_ID, clientSecret: env.BETTER_AUTH_GOOGLE_SECRET, }, }, session: { cookieCache: { enabled: true, maxAge: 60 * 5, // 5 minutes }, }, user: { additionalFields: { balance: { type: "number", defaultValue: 0 }, role: { type: "string", defaultValue: "student", validator: { input: RoleEnum }, input: false, }, }, changeEmail: { enabled: true, sendChangeEmailConfirmation: async ({ user, url, newEmail }) => { void sendChangeEmailEmail(user.name, user.email, newEmail, url); }, }, deleteUser: { enabled: true, sendDeleteAccountVerification: async ({ user, url }) => { void sendAccountDeletionEmail(user.name, user.email, url); }, }, }, plugins: [ nextCookies(), createPassword({ sendCreatePassword: async ({ user, url }) => { void sendPasswordCreateEmail(user.name, user.email, url); }, }), invite({ defaultRoleForSignupWithoutInvite: "student", defaultMaxUses: 1, defaultRedirectTo: "/auth/sign-up", defaultTokenType: "token", canCreateInvite: (inviteUser, inviterUser) => { if (!inviteUser.role || !inviterUser.role) return false; return ( RoleHierarchy[inviterUser.role as RoleType] >= RoleHierarchy[inviteUser.role] ); }, sendUserInvitation: async ({ email, role, url }) => { void sendInvitationEmail(role, email, url); }, }), twoFactor(), adminPlugin({ ac, roles: { student, teacher, admin, owner, }, adminRoles: "owner", defaultRole: "student", }), ], advanced: { cookiePrefix: "recursos", }, database: drizzleAdapter(db, { provider: "pg", }), hooks: { after: createAuthMiddleware(async (ctx) => { if (ctx.path.startsWith("/sign-up")) { const user = ctx.context.newSession?.user ?? { name: ctx.body.name, email: ctx.body.email, }; if (user) { await sendWelcomeEmail(user.name, user.email); } } }), before: createAuthMiddleware(async (ctx) => { if (ctx.path === "/error") { console.log( "redirecting to error url", SIGNIN_ERROR_URL + "?" + ctx.query, ); const queryString = new URLSearchParams(ctx.query).toString(); throw ctx.redirect(SIGNIN_ERROR_URL + "?" + queryString); } if (ctx.path !== "/sign-up/email") { return ctx; } if (!ctx.body?.email.endsWith("@test.com")) { throw new APIError("BAD_REQUEST", { message: "Email must end with @test.com", }); } }), }, }); ``` ### Additional context _No response_
GiteaMirror added the enhancement label 2026-03-13 10:13:50 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 13, 2026):

It seems that you’re encountering an issue where the input: false setting on fields is preventing server-side updates using internalAdapter.updateUser. Currently, any fields marked as input: false are not editable, even programmatically from the server side. This behavior is by design to ensure security and integrity of user data.

However, your expected behavior—allowing server-side updates for such fields—has been noted as a point of discussion in the community, but there is no confirmed solution yet as it involves a certain design philosophy behind input handling in Better Auth.

For further assistance, I recommend checking with Beka and Max, who might be able to provide insights or updates on this matter. You can tag them with @bekacru and @ping__ to get their attention!

Also, have you considered alternative approaches to manage user roles securely? I'd love to hear more about your project and how Better Auth is fitting into it!

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

@better-auth-agent[bot] commented on GitHub (Jan 13, 2026): It seems that you’re encountering an issue where the `input: false` setting on fields is preventing server-side updates using `internalAdapter.updateUser`. Currently, any fields marked as `input: false` are not editable, even programmatically from the server side. This behavior is by design to ensure security and integrity of user data. However, your expected behavior—allowing server-side updates for such fields—has been noted as a point of discussion in the community, but there is no confirmed solution yet as it involves a certain design philosophy behind input handling in Better Auth. For further assistance, I recommend checking with Beka and Max, who might be able to provide insights or updates on this matter. You can tag them with @bekacru and @ping__ to get their attention! Also, have you considered alternative approaches to manage user roles securely? I'd love to hear more about your project and how Better Auth is fitting into it! _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@ping-maxwell commented on GitHub (Jan 13, 2026):

Closing this as expected behavior.
The input: false constraint is intentionally applied to both client (authClient) and server (auth.api) calls. This is by design to maintain consistent validation behavior across all API surfaces.
Consider that developers might blindly pass data through like auth.api.signUpEmail({ body: {...data} }) and expect the same validation behavior as client calls. Having different validation rules between authClient and auth.api could lead to unintended security issues or confusion.
If you need to update fields like role directly on the server, you should use your ORM or database adapter directly:

// Using Drizzle ORM directly
await db.update(user).set({ role: "admin" }).where(eq(user.id, userId));
// Or using the adapter context
await ctx.context.adapter.update({model: "user", data: {role: "..."}});

This gives you full control over server-side updates while keeping the API layer secure and predictable.

@ping-maxwell commented on GitHub (Jan 13, 2026): Closing this as expected behavior. The `input: false` constraint is intentionally applied to both client (authClient) and server (auth.api) calls. This is by design to maintain consistent validation behavior across all API surfaces. Consider that developers might blindly pass data through like `auth.api.signUpEmail({ body: {...data} })` and expect the same validation behavior as client calls. Having different validation rules between authClient and auth.api could lead to unintended security issues or confusion. If you need to update fields like role directly on the server, you should use your ORM or database adapter directly: ``` typescript // Using Drizzle ORM directly await db.update(user).set({ role: "admin" }).where(eq(user.id, userId)); // Or using the adapter context await ctx.context.adapter.update({model: "user", data: {role: "..."}}); ``` This gives you full control over server-side updates while keeping the API layer secure and predictable.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2697