You ship a routine change on a Friday afternoon. Support fills up: everyone has been signed out. Nothing in the diff mentions auth.
Sessions are the one piece of state that lives half in your database and half in a signed cookie in someone's browser, so a surprising range of changes can break the link between the two. This is the map.
What invalidates every session at once
Rotating BETTER_AUTH_SECRET. The secret signs the session cookie. Change
it and every existing signature fails verification: correctly. This is the
number one cause. It happens accidentally when someone regenerates .env from a
template, when a new environment is created by copying and then "securing" it,
or when a secret manager rotates a key on a schedule nobody connected to auth.
Changing the cookie name or prefix. The browser still holds the old cookie; the server looks for a different name and finds nothing. Same visible symptom, different cause.
Changing the cookie domain or secure flag. A cookie set for
app.example.com is not sent to example.com. Moving between them logs
everyone out, and moving back does not restore anything, because the old
cookie was overwritten.
Truncating or recreating the session table. A migrate reset against a
shared database, or a migration that drops and recreates session, removes the
server side of every session. The cookie is valid and points at nothing.
Changing BETTER_AUTH_URL to a different origin. Cookies are host-scoped.
A new origin is a new cookie jar.
What does not invalidate sessions
- Deploying code. A new build with the same secret and the same database keeps every session.
- Adding a column to the user table.
- Changing session
expiresIn. Existing rows keep the expiry they were created with; the new value applies to sessions created from now on. - Restarting the server. Sessions live in Postgres, not in memory.
If everyone is signed out and none of the first list applies, check whether the deployment is talking to a different database than you think: a preview environment pointing at a branch database is the usual culprit.
Rotating the secret on purpose
Sometimes you must: the secret leaked, or an employee with access left. Then signing everyone out is the point. Do it deliberately:
- Announce it, or pick a low-traffic window. Users see a sign-in screen, not an error, but the sudden logout looks like a breach if nobody said anything.
- Change the value in one environment at a time. Preview and production should not share a secret in the first place: a cookie minted on a preview URL should never be valid in production.
- Deploy, then confirm a fresh sign-in works before you walk away.
- Delete the expired session rows afterwards. They are dead weight and they make the table's row count misleading.
There is no way to rotate a signing secret without invalidating what it signed. Anything that claims otherwise is keeping the old secret around, which means the leaked one still works.
Revoking one user, on purpose
This is the operation you actually need most often: a stolen laptop, a compromised mailbox, a demotion that has to bite immediately.
// every session for one user
await auth.api.revokeUserSessions({ body: { userId }, headers: await headers() });
// the caller's other sessions, leaving the current one
await auth.api.revokeOtherSessions({ headers: await headers() });
// one specific session, from a "your devices" list
await auth.api.revokeSession({ body: { token }, headers: await headers() });
The catch: the cookie cache. Better Auth keeps a short signed snapshot of the
session in the cookie so most requests answer without a database read. Revoking
a session deletes the row, but a request holding an unexpired cache entry can
still be served for up to cookieCache.maxAge.
That is a deliberate trade (one database read per request, or a few seconds of
staleness) and it is why maxAge is five minutes in this repo rather than an
hour. For a truly urgent revocation, either disable the cache for the duration
or accept the documented window and say so in your incident notes.
Deleting the session row with raw SQL has the same caveat, and additionally skips whatever the API does around the deletion. Prefer the API.
Banning an account
Revocation ends existing sessions. It does not stop the person signing in again. For that, the admin plugin's ban does both:
await auth.api.banUser({
body: { userId, banReason: "Chargeback fraud", banExpiresIn: 60 * 60 * 24 * 30 },
headers: await headers(),
});
A banned user's sessions stop working and new sign-ins are refused. Make sure
your own guards check the flag too: requireUser in this repo redirects a
banned account to /banned rather than letting it render a shell it no longer
has any business seeing.
Give users their own device list
Half the tickets that start "was that you?" end without you doing anything, if
the settings page can answer it. auth.api.listSessions returns the current
user's sessions with IP and user agent; render them with a revoke button per
row and a "sign out everywhere else" at the bottom. It costs an afternoon and
removes an entire category of support work.
Two details that are easy to get wrong. The list includes each session's
token, and the token is the session: map the rows to an id, a device label
and a date on the server, and revoke by looking the token up again from the
signed-in user's own list inside a server action. And the admin plugin filters
sessions an admin opened by impersonating the user out of that list, so the
person never sees (or revokes) the admin's view of their account. The other
way round matters more: refuse the list to the impersonation session itself,
or the admin reads the user's own session tokens out of it.
Checking your work
- Sign in on two browsers. Revoke from one, and confirm the other is signed out within the cache window.
- Change the secret in a scratch environment and confirm the symptom matches what your users described. Knowing the shape of it saves an hour next time.
- Grep the repo for
BETTER_AUTH_SECRET: it should appear inauth.ts, the env list and the onboarding doc, and nowhere else. - Confirm preview and production hold different secrets.