# 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.

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:

```sh
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:

```ts
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:

| 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):

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
