Recover admin access
What to do when no admin can sign in, for example when the only admin lost their password.
The normal way to recover a lost password is an admin-generated reset link (Settings → Members → Generate reset link). This recipe is for the case where that path is gone: no admin can sign in — typically the instance’s only admin lost their password.
Recovery operates directly on the database. There is deliberately no UI for this: anyone with shell access to the server already owns the instance, and a web-reachable recovery surface would only weaken it.
Both recipes below assume the stock Docker Compose deployment.
If you deploy differently (e.g. a managed Postgres or a PaaS like Coolify),
the SQL is identical — just open psql against whatever database your
DATABASE_URL points at.
Open a database shell
From the directory containing docker-compose.yaml:
docker compose exec db sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"'
This uses the credentials already present inside the db container, so it
works regardless of what you set POSTGRES_USER / POSTGRES_DB to.
List the accounts to find the one you’re recovering (column names are camelCase, so they must be double-quoted in SQL; table names are lowercase):
SELECT id, email, role FROM users;
Promote an existing member to admin
If anyone else on the instance can still sign in, make them an admin — they can then issue a normal reset link for the locked-out account:
UPDATE users SET role = 'admin', "updatedAt" = now()
WHERE email = '[email protected]';
Server-side admin checks read the role fresh on every request, so the promotion is effective immediately. The member’s UI may not show the admin-only surfaces until their cached session refreshes (up to 5 minutes) — signing out and back in picks it up instantly.
Reset the sole admin’s own password
If there is no other account to promote, generate a password-reset token directly in the database, then finish through the app’s normal reset page. The app hashes the new password with better-auth’s own hasher, so nothing hand-rolled ever touches the credential row.
In the psql shell (substitute the admin’s email):
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- The schema allows at most one pending reset per user; clear any leftover.
DELETE FROM password_resets
WHERE "usedAt" IS NULL
AND "userId" = (SELECT id FROM users WHERE email = '[email protected]');
INSERT INTO password_resets (id, token, "userId", "expiresAt", "createdAt")
SELECT gen_random_uuid()::text,
encode(gen_random_bytes(32), 'hex'),
id,
now() + interval '24 hours',
now()
FROM users
WHERE email = '[email protected]'
RETURNING token;
The INSERT prints the generated token:
token
------------------------------------------------------------------
4f1f4ff26f8e6cbd6cb557b8a0d6a4c9…
(1 row)
If it prints (0 rows) instead, the email didn’t match any user — check
SELECT email FROM users; and retry.
Now open the reset page in a browser, substituting your instance’s URL and the token:
https://your-instance.example.com/reset-password?token=<token>
Set a new password. You’ll be signed in immediately; every other session for that account is revoked, and the token is single-use (it also expires after 24 hours).
Notes
CREATE EXTENSION pgcryptorequires superuser. The Compose setup’sPOSTGRES_USERis the cluster superuser, so this just works; on a managed Postgres where it doesn’t, replacegen_random_bytes(32)with any unique string of your own — the token’s only requirements are uniqueness and unguessability.- The reset flow only rewrites the password credential and sessions. Drawings, folders, invites, and other members are untouched.