Found while implementing #9968 (impersonation half). Filed unassigned.
Same class as #10345 (a refusal reaches the client with the status but no machine-readable code), different surface: that one is the /api/v1/data write path, this one is the auth /admin/ namespace.
Measured
Stock better-auth@1.7.1 + its admin plugin, anonymous POST, no session:
/admin/impersonate-user anonymous -> 401 body=""
/admin/set-role anonymous -> 401 body=""
The body is the empty string — not an empty JSON object. It comes from the vendor's adminMiddleware, which is getAuthoritativeSessionFromCtx(ctx) followed by APIError.fromStatus("UNAUTHORIZED"), and every /admin/ route that mounts that middleware inherits it.
Why it is worth recording
The /api/v1/auth/admin/ namespace answers the same question in two different shapes depending on which lane a route happens to be on:
- ObjectStack raw mounts (
create-user, set-user-password, unlock-user, import-users, ban-user, unban-user, oauth2/toggle-disabled, sso/*) answer 401 with the ADR-0112 envelope and code: 'UNAUTHENTICATED' (see platform-admin-gate.ts).
- better-auth-native routes answer
401 with nothing at all.
A client cannot branch on the refusal without knowing, per route, which implementation happens to serve it — and that split is an implementation detail, not a contract.
This asymmetry is already known and encoded in packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts: the ObjectStack bucket asserts anon.status === 401 && anon.code === 'UNAUTHENTICATED', while the better-auth bucket can only assert [401, 403].includes(anon.status) — there is no code to assert. So the suite documents the gap rather than closing it, and nothing tracks closing it.
Not fixed by #9968, deliberately
#9968 re-authorizes /admin/impersonate-user as a better-auth plugin endpoint, changing only the authorization predicate. adminMiddleware (authentication) is passed through untouched, so the bodyless 401 is unchanged there and is pinned as-is rather than dressed up.
Options, no recommendation made unilaterally — this is a public refusal contract:
- A. Accept the split as vendor-lane behaviour and document it in the route ledger, so consumers are told rather than left to discover it.
- B. Normalize the vendor lane's refusals into the ADR-0112 envelope at the
AuthManager.handleRequest seam (one place, all vendor routes) — this changes a public response shape for every better-auth route, so it is a contract change, not a fix.
- C. Narrow to the
/admin/ namespace only, leaving the rest of the vendor surface alone.
Found while implementing #9968 (impersonation half). Filed unassigned.
Same class as #10345 (
a refusal reaches the client with the status but no machine-readable code), different surface: that one is the/api/v1/datawrite path, this one is the auth/admin/namespace.Measured
Stock
better-auth@1.7.1+ itsadminplugin, anonymous POST, no session:The body is the empty string — not an empty JSON object. It comes from the vendor's
adminMiddleware, which isgetAuthoritativeSessionFromCtx(ctx)followed byAPIError.fromStatus("UNAUTHORIZED"), and every/admin/route that mounts that middleware inherits it.Why it is worth recording
The
/api/v1/auth/admin/namespace answers the same question in two different shapes depending on which lane a route happens to be on:create-user,set-user-password,unlock-user,import-users,ban-user,unban-user,oauth2/toggle-disabled,sso/*) answer401with the ADR-0112 envelope andcode: 'UNAUTHENTICATED'(seeplatform-admin-gate.ts).401with nothing at all.A client cannot branch on the refusal without knowing, per route, which implementation happens to serve it — and that split is an implementation detail, not a contract.
This asymmetry is already known and encoded in
packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts: the ObjectStack bucket assertsanon.status === 401 && anon.code === 'UNAUTHENTICATED', while the better-auth bucket can only assert[401, 403].includes(anon.status)— there is no code to assert. So the suite documents the gap rather than closing it, and nothing tracks closing it.Not fixed by #9968, deliberately
#9968 re-authorizes
/admin/impersonate-useras a better-auth plugin endpoint, changing only the authorization predicate.adminMiddleware(authentication) is passed through untouched, so the bodyless 401 is unchanged there and is pinned as-is rather than dressed up.Options, no recommendation made unilaterally — this is a public refusal contract:
AuthManager.handleRequestseam (one place, all vendor routes) — this changes a public response shape for every better-auth route, so it is a contract change, not a fix./admin/namespace only, leaving the rest of the vendor surface alone.