From 5e49c56a9e12a9b6b3fd1202bbc7a2fc97aeeafd Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Tue, 9 Jun 2026 16:59:09 -0700 Subject: [PATCH] fix(auth): mark plugin-owned session fields as non-input (#9965) --- .changeset/update-session-authority-fields.md | 5 ++++ .../src/api/routes/session-api.test.ts | 26 +++++++++++++++++++ packages/better-auth/src/db/schema.ts | 5 ++++ .../better-auth/src/plugins/admin/schema.ts | 1 + .../plugins/organization/organization.test.ts | 2 ++ .../src/plugins/organization/organization.ts | 5 ++++ .../src/plugins/organization/schema.ts | 2 ++ 7 files changed, 46 insertions(+) create mode 100644 .changeset/update-session-authority-fields.md diff --git a/.changeset/update-session-authority-fields.md b/.changeset/update-session-authority-fields.md new file mode 100644 index 0000000000..a082fb560c --- /dev/null +++ b/.changeset/update-session-authority-fields.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Passing `activeOrganizationId`, `activeTeamId`, or `impersonatedBy` to `/update-session` now returns a 400. Change these plugin-managed session fields through their dedicated endpoints instead, such as `organization.setActive`. diff --git a/packages/better-auth/src/api/routes/session-api.test.ts b/packages/better-auth/src/api/routes/session-api.test.ts index 7b1eb10852..12723debff 100644 --- a/packages/better-auth/src/api/routes/session-api.test.ts +++ b/packages/better-auth/src/api/routes/session-api.test.ts @@ -17,6 +17,8 @@ import { } from "vitest"; import { parseCookies, parseSetCookieHeader } from "../../cookies"; import { signJWT, verifyJWT } from "../../crypto"; +import { admin } from "../../plugins/admin"; +import { organization } from "../../plugins/organization"; import { getTestInstance } from "../../test-utils/test-instance"; import { getDate } from "../../utils/date"; import { freshSessionMiddleware, getSessionFromCtx } from "./session"; @@ -2208,3 +2210,27 @@ describe("updateSession", async () => { }); }); }); + +describe("updateSession plugin authority fields", async () => { + // Plugin-owned authority fields must not be writable through the generic + // session update route. They are set only by membership/permission-checked + // setters (setActiveOrganization, impersonateUser), so /update-session must + // reject them even though they live on the session schema. + const { client, signInWithTestUser } = await getTestInstance({ + plugins: [organization({ teams: { enabled: true } }), admin()], + }); + + it.each([ + "activeOrganizationId", + "activeTeamId", + "impersonatedBy", + ])("should reject forging the %s session field", async (field) => { + const { runWithUser } = await signInWithTestUser(); + await runWithUser(async () => { + const res = await client.updateSession({ + [field]: "forged-value", + } as any); + expect(res.error?.status).toBe(400); + }); + }); +}); diff --git a/packages/better-auth/src/db/schema.ts b/packages/better-auth/src/db/schema.ts index 83a83b2956..d4772d0a8a 100644 --- a/packages/better-auth/src/db/schema.ts +++ b/packages/better-auth/src/db/schema.ts @@ -40,6 +40,11 @@ function getFields( ...coreSchema, ...(additionalFields ?? {}), }; + // FIXME: Plugin-contributed fields are input-by-default, so a plugin-owned + // authority field is writable through generic input routes (e.g. + // /update-session) unless it sets `input: false`. A future breaking change + // should make plugin fields non-input by default and require an explicit + // opt-in for client-writable ones. for (const plugin of options.plugins || []) { if (plugin.schema && plugin.schema[modelName]) { schema = { diff --git a/packages/better-auth/src/plugins/admin/schema.ts b/packages/better-auth/src/plugins/admin/schema.ts index 8ae8b161c5..8c545b5c47 100644 --- a/packages/better-auth/src/plugins/admin/schema.ts +++ b/packages/better-auth/src/plugins/admin/schema.ts @@ -31,6 +31,7 @@ export const schema = { impersonatedBy: { type: "string", required: false, + input: false, }, }, }, diff --git a/packages/better-auth/src/plugins/organization/organization.test.ts b/packages/better-auth/src/plugins/organization/organization.test.ts index a721ab7000..10d5b9ea86 100644 --- a/packages/better-auth/src/plugins/organization/organization.test.ts +++ b/packages/better-auth/src/plugins/organization/organization.test.ts @@ -166,11 +166,13 @@ describe("organization", async () => { activeOrganizationId: { type: "string"; required: false; + input: false; }; } & { activeTeamId: { type: "string"; required: false; + input: false; }; }; diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts index f1da9c7b91..cd25e2a355 100644 --- a/packages/better-auth/src/plugins/organization/organization.ts +++ b/packages/better-auth/src/plugins/organization/organization.ts @@ -1221,6 +1221,7 @@ export function organization(options?: O) { activeOrganizationId: { type: "string", required: false, + input: false, fieldName: opts.schema?.session?.fields?.activeOrganizationId, }, ...(teamSupport @@ -1228,6 +1229,7 @@ export function organization(options?: O) { activeTeamId: { type: "string", required: false, + input: false, fieldName: opts.schema?.session?.fields?.activeTeamId, }, } @@ -1239,16 +1241,19 @@ export function organization(options?: O) { activeTeamId: { type: "string"; required: false; + input: false; }; activeOrganizationId: { type: "string"; required: false; + input: false; }; } : { activeOrganizationId: { type: "string"; required: false; + input: false; }; }, }, diff --git a/packages/better-auth/src/plugins/organization/schema.ts b/packages/better-auth/src/plugins/organization/schema.ts index bd60468592..ecc18bdb4e 100644 --- a/packages/better-auth/src/plugins/organization/schema.ts +++ b/packages/better-auth/src/plugins/organization/schema.ts @@ -199,6 +199,7 @@ interface SessionDefaultFields { activeOrganizationId: { type: "string"; required: false; + input: false; }; } @@ -278,6 +279,7 @@ export type OrganizationSchema = activeTeamId: { type: "string"; required: false; + input: false; }; } : {});