organization: `create-role` and `update-role` accept a comma in the role name, persisting a role that can never be assigned
Is this suited for github?
- Yes, this is suited for github
Reproduction
Minimal repro on a clean [email protected] project (in-memory adapter, no external DB):
// repro.mjs — node repro.mjs
import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";
import { organization } from "better-auth/plugins";
import { createAccessControl } from "better-auth/plugins/access";
import { adminAc, defaultStatements, memberAc, ownerAc } from "better-auth/plugins/organization/access";
const statement = { ...defaultStatements, project: ["read", "write"] };
const ac = createAccessControl(statement);
const roles = {
owner: ac.newRole({ ...ownerAc.statements, project: ["read", "write"] }),
admin: ac.newRole({ ...adminAc.statements, project: ["read"] }),
member: ac.newRole({ ...memberAc.statements, project: ["read"] }),
};
const db = { user: [], session: [], account: [], verification: [], organization: [], member: [], invitation: [], organizationRole: [], team: [], teamMember: [] };
const auth = betterAuth({
baseURL: "http://localhost:3000",
secret: "a-very-long-test-secret-value-for-repro-0123456789",
database: memoryAdapter(db),
emailAndPassword: { enabled: true },
plugins: [organization({ ac, roles, dynamicAccessControl: { enabled: true } })],
});
const signUp = await auth.api.signUpEmail({
body: { email: "[email protected]", password: "password12345", name: "Owner" },
returnHeaders: true,
});
const headers = new Headers({ cookie: (signUp.headers.get("set-cookie") ?? "").split(";")[0] });
const org = await auth.api.createOrganization({ body: { name: "Acme", slug: "acme" }, headers });
// 1. create a role whose NAME contains the list separator
const created = await auth.api.createOrgRole({
body: { organizationId: org.id, role: "owner,temp", permission: { project: ["read"] } },
headers,
});
console.log("created:", created.success, created.roleData.role);
// 2. try to assign it
try {
await auth.api.updateMemberRole({
body: { organizationId: org.id, memberId: db.member[0].id, role: "owner,temp" },
headers,
});
} catch (e) { console.log("assign rejected:", e.body); }
// 3. rename a clean role into a comma name
await auth.api.createOrgRole({ body: { organizationId: org.id, role: "reporter", permission: { project: ["read"] } }, headers });
const renamed = await auth.api.updateOrgRole({
body: { organizationId: org.id, roleName: "reporter", data: { roleName: "owner,reporter" } },
headers,
});
console.log("renamed to:", renamed.roleData.role);
console.log("stored roles:", db.organizationRole.map((r) => r.role));Observed output:
created: true owner,temp
assign rejected: { code: 'ROLE_NOT_FOUND', message: 'ROLE_NOT_FOUND: temp' }
renamed to: owner,reporter
stored roles: [ 'owner,temp', 'owner,reporter' ]Current vs. Expected behavior
Current: create-role and update-role accept any string as a role name and persist it verbatim. The body schema is role: z.string() with no character validation, and normalizeRoleName only lowercases. The reserved-name guard compares the whole string, so ["owner","admin",...].includes("owner,temp") is false and the name is stored. Both a fresh create and a rename succeed.
Expected: a role name containing , is rejected at creation and at rename, because , is the list separator: Member.role stores roles comma-joined and hasPermissionFn resolves them with role.split(",").
The resulting row is inert but incoherent. As the output above shows, the role can never be assigned — updateMemberRole splits the value and validates each token, so owner,temp fails with ROLE_NOT_FOUND: temp. So creation succeeds in producing a role that no endpoint can ever use, which still occupies a row, appears in list-roles, and counts against maximumRolesPerOrganization. In-use checks that compare split tokens (for example delete-role's scan for members still holding a role) can never match it either.
What version of Better Auth are you using?
1.7.5
System info
{
"system": {
"platform": "darwin",
"arch": "arm64",
"release": "25.6.0",
"cpuCount": 10,
"cpuModel": "Apple M5",
"totalMemory": "24.00 GB"
},
"node": { "version": "v24.18.0", "env": "development" },
"packageManager": { "name": "npm", "version": "11.16.0" },
"frameworks": null,
"databases": null,
"betterAuth": { "version": "1.7.5", "config": null }
}Which area(s) are affected? (Select all that apply)
Backend
Auth config (if applicable)
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
import { createAccessControl } from "better-auth/plugins/access";
import { defaultStatements, ownerAc } from "better-auth/plugins/organization/access";
const ac = createAccessControl({ ...defaultStatements, project: ["read", "write"] });
export const auth = betterAuth({
emailAndPassword: { enabled: true },
plugins: [
organization({
ac,
roles: { owner: ac.newRole({ ...ownerAc.statements, project: ["read", "write"] }) },
dynamicAccessControl: { enabled: true },
}),
],
});Additional context
This is not a security report. The privilege-escalation path through this was fixed by #9962 and I verified that independently against the published tarballs: 1.6.16 affected / 1.6.17 not, and 1.7.0-beta.5 affected / 1.7.0-beta.6 not. On 1.7.5 every web-reachable writer of Member.role splits before resolving (update-member-role and the invitation paths; addMember is server-only), which is why the assign step above is rejected. Filing this as ordinary hardening, not a vulnerability.
Why it still seems worth fixing. #9962's changeset already states the principle: "unknown or malformed role values are rejected instead of being persisted." That was applied to updateMemberRole; the creation endpoints still persist malformed names. #9962 also added a test named "should not allow a comma-delimited role string" — that guarantee currently holds only at the assignment chokepoint. hasPermissionFn still splits unconditionally, so the invariant "a role name never contains the list separator" depends on every present and future writer of Member.role remembering to split first. Validating at creation removes that class of regression instead of defending against it one endpoint at a time.
Note on the pending rewrite (#7886). This survives it: the new packages/organization/src/addons/dynamic-access-control/routes/create-role.ts still uses role: z.string().min(1) plus a whole-string reserved check, and packages/organization/src/helpers/validate-roles.ts only validates that roles are recognized — it splits on , itself. validate-roles.ts looks like the natural home for a shared name-validation rule if you'd prefer it centralized.
Suggested fix. Reject , in role names at create-role and at update-role (data.roleName); cheapest form is a refinement on the existing zod schema.
Source: better-auth/better-auth