diff --git a/CLAUDE.md b/CLAUDE.md
index 414a8f9..ff8c7fa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -235,6 +235,23 @@ Key facts:
`fetch('/api/me')` auth wall; `/login/` and `/register/` have `public={true}` to skip it
- Incremental rebuild: `POST /api/activity/{id}` triggers `bincio render --handle {user}`
as a fire-and-forget subprocess (only if `--site-dir` was passed to `bincio serve`)
+
+### Password reset (no email — out-of-band code)
+
+There is no email infrastructure. Password resets work via admin-generated one-time codes:
+
+1. **Admin** opens `/admin/` → clicks **"Reset pwd"** next to the user → a code appears
+ inline (monospace, click to copy). Valid for **24 hours**, tied to that handle.
+2. **Admin** sends the code out-of-band (Signal, Telegram, etc.).
+3. **User** goes to `/reset-password/`, enters handle + code + new password → done.
+
+API:
+- `POST /api/admin/users/{handle}/reset-password-code` (admin) → `{code, expires_in_hours: 24}`
+- `POST /api/auth/reset-password` (public) → body `{handle, code, password}`
+
+DB: `reset_codes` table `(code, handle, created_by, created_at, expires_at, used_at)`.
+Generating a new code invalidates any prior unused code for the same handle.
+Used codes are kept for audit. `change_password()` in `db.py` updates the bcrypt hash.
- Write API in `bincio serve` delegates to `bincio.edit.server._apply_sidecar_edit`; the
Strava sync delegates to `bincio.edit.server.strava_sync` with a temporary data_dir swap
diff --git a/site/src/pages/reset-password/index.astro b/site/src/pages/reset-password/index.astro
index 8051850..1379dad 100644
--- a/site/src/pages/reset-password/index.astro
+++ b/site/src/pages/reset-password/index.astro
@@ -4,7 +4,8 @@ import Base from '../../layouts/Base.astro';
Enter the reset code you received from the admin.
+Enter the reset code you received from the admin.
+Don't have a code? Contact the instance admin — they can generate one for you from the admin panel. Codes expire after 24 hours.