Two holes show up in almost every Better Auth app with an admin panel. Both are closed in the same place.
Hole 1: the settings page is read-only, the API is not
An admin clicks "Impersonate". The settings page sees impersonatedBy on the
session, greys out the password form and hides "Sign out other sessions". The
server actions check it too. Looks locked.
It is not. The impersonation session is a real session for the user, and every Better Auth endpoint is a public URL:
curl -X POST https://app.example.com/api/auth/change-password \
-H 'origin: https://app.example.com' -H 'content-type: application/json' \
-b 'better-auth.session_token=<the impersonation cookie>' \
--data '{"currentPassword":"...","newPassword":"..."}'
Better Auth has no idea your app wanted that session read-only. The same goes
for /update-user, /revoke-other-sessions, /unlink-account,
/link-social, /delete-user. And two reads are worse than they look:
/list-sessions answers with every session's token, which is a clean session
for the user with no impersonation mark on it, and /get-access-token hands
over their Google or GitHub token.
Hole 2: adding a password only needs a session
setPassword adds a password to an account that signed up with Google or a
magic link. Better Auth wants a valid session for it, not a recent one. So a
stolen cookie, or a laptop left open, is enough to add a password the thief
knows. Revoke the cookie later and they sign in with the password.
/link-social has the same shape: connect the attacker's own Google account
and keep the account. So does /change-email, if you turn it on.
/change-password is fine, it needs the current password. /unlink-account
already needs a sign-in from the last day (freshAge).
One hook, at the auth layer
Better Auth runs hooks.before for every endpoint, whether the call comes over
HTTP or from your own server code through auth.api.*. Put the checks there:
import { APIError, createAuthMiddleware } from "better-auth/api";
const endpointGuard = {
id: "endpoint-guard",
hooks: {
before: [
{
matcher: () => true,
handler: createAuthMiddleware(async (ctx) => {
const call = { path: ctx.path, operationId: (ctx as { operationId?: unknown }).operationId };
if (!needsSessionCheck(call)) return;
const token = await ctx.getSignedCookie(
ctx.context.authCookies.sessionToken.name,
ctx.context.secret,
);
if (!token) return;
const current = await ctx.context.internalAdapter.findSession(token);
const refusal = guardAuthEndpoint(call, current?.session ?? null, Date.now());
if (refusal) throw new APIError("FORBIDDEN", refusal);
}),
},
],
},
} satisfies BetterAuthPlugin;
betterAuth({
// ...
plugins: [/* your plugins */, endpointGuard, nextCookies()],
});
guardAuthEndpoint is a pure function you can unit-test. Details that matter:
- Match
ctx.path, not the URL. It is the route the endpoint declares (/change-password,/callback/:id). A trailing slash, a different case or an encoded dash never reaches an endpoint, so there is nothing to normalise. - Server-only endpoints have no route.
setPasswordshows up withctx.pathof/andoperationIdof"setPassword". Match it by that name, and keep a second check in the server action that calls it, so the one change that must never lose its check does not rest on an internal field. - Read the session from the database. The cookie cache is fine for "who
is this", but a check should use the row.
findSessionalso throws when the database fails, so the call is refused rather than let through. - A plugin, registered last before
nextCookies(). Plugin hooks run after the top-levelhooks.before, in plugin order. A plugin that signs requests in from a header (bearer, JWT) does it in its own hook, and the guard needs to see the session it produced.
An allowlist for impersonation
List what an impersonation session may call, and refuse everything else:
| Allowed | Why |
|---|---|
/get-session | every page asks who is signed in |
/list-accounts | the Security page shows the sign-in methods |
/sign-out, /admin/stop-impersonating | ending it must always work |
/ok, /error | harmless |
A blocklist of "account-changing endpoints" is out of date the day you add a
plugin or upgrade. An allowlist refuses the new endpoint until someone decides
it is safe. Unit-test it against the full endpoint list of the version you
run (Object.values(auth.api).map((e) => e.path)), so an upgrade shows up as
a failing test, not a quiet gap.
Show the refusal honestly in the UI too: read-only forms, a banner, and the Security page skipping the sessions list entirely rather than asking for it and failing.
A recent sign-in before adding a way in
For setPassword, /link-social and /change-email, compare
session.createdAt with a window (15 minutes is plenty for someone who just
signed in to do it):
export const RECENT_SIGN_IN_MINUTES = 15;
export function isRecentSignIn(createdAt: Date | string | number, now: number): boolean {
const started = new Date(createdAt).getTime();
if (Number.isNaN(started)) return false;
const age = now - started;
return age >= 0 && age < RECENT_SIGN_IN_MINUTES * 60_000;
}
createdAt is when the person signed in. Better Auth's sliding refresh moves
expiresAt and updatedAt, never createdAt, so an active session does not
count as a recent sign-in forever.
When the session is too old, do not show a dead end. Show a "Confirm it's
you" step with one button: sign this session out, open sign-in with
?next=/settings/security, and the person lands back on the same card with a
new session. The page can know in advance (it has the session), so ask before
they type a password, and handle the server's refusal too in case the page sat
open past the window.
Checking your work
- Impersonate a user, copy the cookie, and
curl/change-password,/update-user,/revoke-other-sessions,/list-sessions: each 403, and the user's rows unchanged. - Same cookie:
/get-sessionworks,/admin/stop-impersonatingputs the admin back. - Sign in with a magic link, set
session.created_atto 20 minutes ago in the database, then try to set a password: refused, "Confirm it's you" shown. Sign in again: it works. - A normal user, freshly signed in, can still change their name, password and sessions.