Security is the feature we'd rather you never have to think about. This release is mostly that kind of work — nothing flashy, but a lot of small steps that add up to a platform that's meaningfully harder to attack.
TL;DR — v3.6.0 encrypts 2FA seeds and OAuth tokens at rest, closes an email-enumeration leak on sign-up, tightens the idle session timeout to 15 minutes, makes password-reset tokens atomic one-time-use, and makes the TOTP validation window symmetric. Everything was shipped behind a backfill migration so existing accounts keep working without manual intervention.
Here's what changed, and why each one matters.
TOTP seeds are now encrypted at rest
When you enable two-factor authentication with an authenticator app, the server has to remember the shared TOTP seed so it can verify your 6-digit codes. Until this release, that seed sat in the database as plaintext.
It was literally annotated in the codebase as "In production, encrypt this." — a TODO we should have retired sooner.
Now every TOTP seed is wrapped with AES-256-GCM using a dedicated encryption key before it touches the database. A compromise of the database alone no longer compromises anyone's 2FA.
Crucially, we built the change to be migration-safe. Every encrypted value carries an enc:v1: prefix so readers can tell "this is encrypted" from "this is a legacy plaintext row we haven't backfilled yet." The verify path transparently handles both, so a running production doesn't break while we backfill.
We ship the backfill as a dedicated command — npm run db:migrate:secrets:commit — with a dry-run mode (db:migrate:secrets) that reports scanned, encrypted, already-encrypted, and error counts before touching anything. The script is idempotent: safe to re-run, safe to stop and resume.
OAuth access tokens join the encrypted club
The same treatment applies to OAuth access and refresh tokens stored against oauth_account_links.
Google, GitHub, and our generic OAuth provider flow now all encrypt the token on the way in. The admin GitHub integration reads decrypt transparently, so private-repo access keeps working without any operator intervention.
Sign-up no longer leaks which emails are registered
Previously, signing up with an existing email returned a 409 conflict with a tell: "An account with this email already exists." That's convenient for the user — and it's also convenient for anyone enumerating valid accounts.
Now, sign-up returns the same generic success shape whether the address is new or already registered. We even burn a comparable amount of CPU on the existing-user path (a throwaway bcrypt hash) so response timing doesn't betray which path we took.
- New users get a session and a verification email.
- Existing users get silence. No information leak.
This pairs with the new verification gate (next section), which reshapes sign-up from "create and auto-login" into "create, send verification link, block deploys until verified."
Verification emails — and a blocking gate on deploys
Sign-ups now trigger a verification email with a signed link good for 24 hours.
Clicking it hits a new /api/auth/verify-email route that atomically consumes the token and flips emailVerified on the user record. We then land you on /verify-email with a success, expired, already-used, or missing-token state.
You can re-send at any time via POST /api/auth/resend-verification (rate-limited to 3 attempts per hour — and no, the unauthenticated variant doesn't tell you whether your address is registered).
Most importantly, the deploy endpoint is now gated: if your email isn't verified, we return 403 with code EMAIL_NOT_VERIFIED. The wizard surfaces this as a "check your inbox" prompt. It's a small piece of friction that substantially raises the bar on abuse.
SessionGuard tightened to 15 minutes
The server-side idle timeout was sitting at 30 minutes — which didn't match what the SessionGuard client was already counting down on your screen. That meant, in theory, a stolen cookie could stay valid for longer than your session looked valid.
We brought the server in line with the spec: 15 minutes of no-heartbeat and the session is invalidated. The existing 2-minute and 1-minute client-side warnings now correspond to the actual server behaviour.
Password-reset tokens are now atomic one-time-use
The password-reset flow used to look roughly like:
find token → verify it's not expired → update password → delete token
If two requests ever hit the same token at the same time, both could pass the "not yet deleted" check before either reached the delete, and you'd end up with two concurrent password changes. That's a narrow race, but it's a race.
The fix is boring and correct: delete the token before doing anything else. Prisma's delete on a unique key throws P2025 if the row is already gone, so exactly one of the racing requests wins. The loser is rejected with "Token has already been used." No password changes for the loser, no weird state.
TOTP verification window symmetry
We already validated TOTP codes with window: 0 — only the current 30-second period, which closes a replay attack against previously valid codes. But enable-2fa (the initial setup path) was still using window: 1, which accepts one period back and one forward.
It's a small asymmetry, but it's an asymmetry. Both paths now use window: 0. The 30-second period itself still gives you generous clock-skew tolerance, so correct users don't notice anything.
What you should do
- If you use 2FA on Boottify — you don't have to do anything. Your TOTP seed will be re-encrypted during the backfill migration, and nothing changes for you.
- If you have an OAuth-linked account (Google or GitHub) — same story. Tokens will be rotated into their encrypted form during backfill; your login flow is unaffected.
- If you signed up but never verified your email — you'll see the new verification prompt the next time you try to deploy. Click the link in your inbox, or request a new one, and you're good.
Thanks for reading. If you want the same level of detail on the mobile modal work or the design-system cleanup from v3.6.0, those posts are queued up next.



