Skip to content

Guard Better Auth's endpoints, not just your settings forms

Every /api/auth endpoint is a public URL. One hook refuses account changes from an impersonation session and asks for a recent sign-in before a password or provider is added.

Better Auth4 min readships at docs/solutions/better-auth/guarding-the-auth-api-itself.md

Tags: better-auth · impersonation · sessions · hooks · security

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. setPassword shows up with ctx.path of / and operationId of "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. findSession also 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-level hooks.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:

AllowedWhy
/get-sessionevery page asks who is signed in
/list-accountsthe Security page shows the sign-in methods
/sign-out, /admin/stop-impersonatingending it must always work
/ok, /errorharmless

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-session works, /admin/stop-impersonating puts the admin back.
  • Sign in with a magic link, set session.created_at to 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.