Observed 2026-08-18 while building the derived /admin/ route-walk for #9482. Filed unassigned, finding — low severity, and recorded mainly because it is what makes a naive route-walking security test vacuous.
What was measured
/admin/unlock-user is representative (auth-plugin.ts), and /admin/oauth2/toggle-disabled plus the /admin/sso/* bridges follow the same order:
1. parse body
2. if (!userId) return 400 INVALID_REQUEST <-- before any identity is established
3. session = await authApi.getSession(...)
4. if (!session) return 401 UNAUTHENTICATED
5. if (!isAdmin) return 403 PERMISSION_DENIED
So an anonymous POST with an empty body draws:
POST /admin/unlock-user {} -> 400 {"code":"INVALID_REQUEST","message":"userId is required"}
POST /admin/sso/register {} -> 400 {"code":"INVALID_REQUEST","message":"Missing required field(s): providerId, issuer, domain, clientId, clientSecret"}
better-auth's own admin endpoints do the same, one layer down — an empty-body /admin/ban-user answers 400 VALIDATION_ERROR [body.userId] to anonymous, member and platform admin alike.
Why it matters, and why it is small
The disclosure is a parameter-shape oracle to an unauthenticated caller: field names and which are required. That is close to free information for a documented API, which is why this is filed as a finding rather than a defect. Nothing is read, written or leaked beyond the schema shape, and with a valid payload the gate still refuses correctly (403 PERMISSION_DENIED on the ObjectStack mounts).
The larger practical consequence is for testing, and it is the reason this is written down at all: a route-walking security probe built on empty bodies never reaches the authorization check on any of these routes. It observes a 400 for the member, a 400 for the admin, asserts "non-2xx", and goes green while proving nothing about the gate. #9482's pin fires every route with a payload valid enough to reach authorization for exactly this reason, and its header records the measurement.
If it is ever addressed, the fix is an ordering change — authenticate, then authorize, then validate — which is a behaviour change on ~9 routes and wants its own decision rather than a rider on a test card.
Observed 2026-08-18 while building the derived
/admin/route-walk for #9482. Filed unassigned,finding— low severity, and recorded mainly because it is what makes a naive route-walking security test vacuous.What was measured
/admin/unlock-useris representative (auth-plugin.ts), and/admin/oauth2/toggle-disabledplus the/admin/sso/*bridges follow the same order:So an anonymous POST with an empty body draws:
better-auth's own admin endpoints do the same, one layer down — an empty-body
/admin/ban-useranswers400 VALIDATION_ERROR [body.userId]to anonymous, member and platform admin alike.Why it matters, and why it is small
The disclosure is a parameter-shape oracle to an unauthenticated caller: field names and which are required. That is close to free information for a documented API, which is why this is filed as a finding rather than a defect. Nothing is read, written or leaked beyond the schema shape, and with a valid payload the gate still refuses correctly (
403 PERMISSION_DENIEDon the ObjectStack mounts).The larger practical consequence is for testing, and it is the reason this is written down at all: a route-walking security probe built on empty bodies never reaches the authorization check on any of these routes. It observes a 400 for the member, a 400 for the admin, asserts "non-2xx", and goes green while proving nothing about the gate. #9482's pin fires every route with a payload valid enough to reach authorization for exactly this reason, and its header records the measurement.
If it is ever addressed, the fix is an ordering change — authenticate, then authorize, then validate — which is a behaviour change on ~9 routes and wants its own decision rather than a rider on a test card.