fix(security): upgrade better-auth to 1.7.14 #75

Merged
eliott merged 1 commit from security/better-auth-1.7 into main 2026-10-28 20:41:28 +01:00
Owner

What changed

  • better-auth, @better-auth/passkey and @better-auth/expo: 1.6.33 -> 1.7.14 (latest 1.7.x), bumped in lockstep. The lockfile now has a single 1.7.14 for every @better-auth/* package. @thallesp/nestjs-better-auth 2.6.0 already accepts better-auth >=1.5.0 <2.0.0.
  • New migration drizzle/0031_two_factor_lockout.sql (generated with pnpm drizzle-kit generate), with the matching columns in src/db/auth-schema.ts:
    ALTER TABLE "two_factor" ADD COLUMN "failed_verification_count" integer DEFAULT 0;
    ALTER TABLE "two_factor" ADD COLUMN "locked_until" timestamp;
    
  • No changes to application code. src/auth.ts builds without edits.

Base is main, the branch Coolify deploys, same as #74. develop is currently one commit behind main (the #74 merge), so it needs a sync after this lands. package.json version is unchanged (non-develop PR rule). Bump it before merging if you want a v1.1.3 release.

Advisories fixed

Severity Advisory Package Fixed in
critical GHSA-965c-763c-88jm - OAuth state can be used as a Magic Link to sign in as another user better-auth 1.7.7
high GHSA-r4xp-prcw-77qf - OAuth Proxy lets an attacker sign in as another user better-auth 1.7.7
high GHSA-q84f-53jg-9ppm - device login approval does not show the requesting client better-auth 1.7.0-rc.3
low GHSA-44jh-23m7-hpcf - concurrent requests can exceed DB-backed rate limits better-auth, @better-auth/drizzle-adapter, @better-auth/kysely-adapter 1.7.7

We don't use magic link, OAuth proxy or device authorization, so the critical and high findings were not reachable in our config. Grype flags the package version regardless, so the upgrade clears the image scan.

pnpm audit --prod: before, 6 findings (1 critical, 2 high, 3 low, all better-auth). After: No known vulnerabilities found.

Why the migration is required

The twoFactor plugin added account-lockout columns (failedVerificationCount, lockedUntil) in late 1.6. I checked the published tarballs: they are absent in 1.6.20 and present from 1.6.25. Our Drizzle schema never got them. 1.7.3+ checks the Drizzle schema at boot and rejects every auth request on a mismatch, in every environment. I ran auth-email-password.int-spec.ts without the schema change and all 6 tests failed, with:

[Better Auth]: Drizzle schema mismatch
  Missing columns
    twoFactor.failedVerificationCount
    twoFactor.lockedUntil

Both columns are nullable or defaulted, so the migration is additive and safe on a live table. main.ts applies it on boot, before Better-Auth handles requests.

There is no other schema change between 1.6.33 and 1.7.14 for what we use (core, username, twoFactor, passkey, bearer, expo). I dumped getAuthTables() for our plugin set under both versions and the field sets are identical, which means the lockout columns were already expected by 1.6.33. The 1.7.0 account.issuer column and its backfill were reverted in 1.7.3, and the upgrade guide confirms the account schema is unchanged from 1.6. drizzle-kit generate produced only the two statements above, so there is no other drift.

Side note: since #74 (1.6.12 -> 1.6.33), production has been running a twoFactor plugin that writes these two columns without them in the schema. I haven't checked whether 2FA verification fails today, but it's worth a look in the prod logs.

Other 1.7 changes reviewed (no action needed)

  • enableTwoFactor now returns a method field. It defaults to "totp" and still returns totpURI/backupCodes. The front calls twoFactor.enable({ password }) and reads totpURI, so it keeps working.
  • Origin from Host with a dynamic baseURL: our baseURL is static (BETTER_AUTH_URL).
  • Captcha path matching, joins option, generic OAuth, SSO/SCIM/MCP/OIDC changes: none of these are used here.
  • Cookie attributes aligned with RFC 6265bis (1.7.8), and the cookie cache is now tied to the session_token cookie. Existing sessions should survive, but some users may get signed out once.
  • Expo client storage is now async. That only affects the native app when it upgrades its own client: rollnote-app stays on its 1.6.11 client, which talks to a 1.7 server fine.

Tests

  • pnpm install --frozen-lockfile: ok
  • pnpm lint: ok
  • pnpm exec tsc --noEmit -p tsconfig.json: ok
  • pnpm build: ok
  • pnpm openapi:generate: no drift in openapi.json
  • pnpm test (unit): 31 suites, 302 tests passed
  • pnpm test:int (testcontainers on rootless podman, Postgres 18 + Mailpit, all migrations including 0031): 34 suites, 333 tests passed
  • .pnpmfile.cjs still keeps drizzle-kit, tsx and esbuild out of the prod dependency tree (pnpm ls --prod finds none).

I didn't run the Docker image build or a Grype scan locally. CI runs the Docker build.

To check after deploy

  1. Boot logs: migration 0031 applied, and no [Better Auth]: Drizzle schema mismatch line.
  2. Password login (username + password), sign-up email verification, and password reset.
  3. 2FA: sign in on an account with TOTP enabled, then with the email OTP, then with a backup code. Also enable 2FA on a fresh account and check the QR/URI shows up.
  4. Passkey: register one, then sign in with it.
  5. Native app (Expo): sign in and confirm the session survives an app restart.
  6. ZIP export/import: not auth-related (integration tests pass), but a quick import on a test account doesn't hurt.
  7. Re-run Grype on the new image. The better-auth findings should be gone.
## What changed - `better-auth`, `@better-auth/passkey` and `@better-auth/expo`: 1.6.33 -> 1.7.14 (latest 1.7.x), bumped in lockstep. The lockfile now has a single 1.7.14 for every `@better-auth/*` package. `@thallesp/nestjs-better-auth` 2.6.0 already accepts `better-auth >=1.5.0 <2.0.0`. - New migration `drizzle/0031_two_factor_lockout.sql` (generated with `pnpm drizzle-kit generate`), with the matching columns in `src/db/auth-schema.ts`: ```sql ALTER TABLE "two_factor" ADD COLUMN "failed_verification_count" integer DEFAULT 0; ALTER TABLE "two_factor" ADD COLUMN "locked_until" timestamp; ``` - No changes to application code. `src/auth.ts` builds without edits. Base is `main`, the branch Coolify deploys, same as #74. `develop` is currently one commit behind `main` (the #74 merge), so it needs a sync after this lands. `package.json` version is unchanged (non-develop PR rule). Bump it before merging if you want a `v1.1.3` release. ## Advisories fixed | Severity | Advisory | Package | Fixed in | |---|---|---|---| | critical | GHSA-965c-763c-88jm - OAuth state can be used as a Magic Link to sign in as another user | better-auth | 1.7.7 | | high | GHSA-r4xp-prcw-77qf - OAuth Proxy lets an attacker sign in as another user | better-auth | 1.7.7 | | high | GHSA-q84f-53jg-9ppm - device login approval does not show the requesting client | better-auth | 1.7.0-rc.3 | | low | GHSA-44jh-23m7-hpcf - concurrent requests can exceed DB-backed rate limits | better-auth, @better-auth/drizzle-adapter, @better-auth/kysely-adapter | 1.7.7 | We don't use magic link, OAuth proxy or device authorization, so the critical and high findings were not reachable in our config. Grype flags the package version regardless, so the upgrade clears the image scan. `pnpm audit --prod`: before, 6 findings (1 critical, 2 high, 3 low, all better-auth). After: **No known vulnerabilities found**. ## Why the migration is required The twoFactor plugin added account-lockout columns (`failedVerificationCount`, `lockedUntil`) in late 1.6. I checked the published tarballs: they are absent in 1.6.20 and present from 1.6.25. Our Drizzle schema never got them. 1.7.3+ checks the Drizzle schema at boot and **rejects every auth request on a mismatch**, in every environment. I ran `auth-email-password.int-spec.ts` without the schema change and all 6 tests failed, with: ``` [Better Auth]: Drizzle schema mismatch Missing columns twoFactor.failedVerificationCount twoFactor.lockedUntil ``` Both columns are nullable or defaulted, so the migration is additive and safe on a live table. `main.ts` applies it on boot, before Better-Auth handles requests. There is no other schema change between 1.6.33 and 1.7.14 for what we use (core, username, twoFactor, passkey, bearer, expo). I dumped `getAuthTables()` for our plugin set under both versions and the field sets are identical, which means the lockout columns were already expected by 1.6.33. The 1.7.0 `account.issuer` column and its backfill were reverted in 1.7.3, and the upgrade guide confirms the account schema is unchanged from 1.6. `drizzle-kit generate` produced only the two statements above, so there is no other drift. Side note: since #74 (1.6.12 -> 1.6.33), production has been running a twoFactor plugin that writes these two columns without them in the schema. I haven't checked whether 2FA verification fails today, but it's worth a look in the prod logs. ## Other 1.7 changes reviewed (no action needed) - `enableTwoFactor` now returns a `method` field. It defaults to `"totp"` and still returns `totpURI`/`backupCodes`. The front calls `twoFactor.enable({ password })` and reads `totpURI`, so it keeps working. - Origin from `Host` with a dynamic `baseURL`: our `baseURL` is static (`BETTER_AUTH_URL`). - Captcha path matching, joins option, generic OAuth, SSO/SCIM/MCP/OIDC changes: none of these are used here. - Cookie attributes aligned with RFC 6265bis (1.7.8), and the cookie cache is now tied to the `session_token` cookie. Existing sessions should survive, but some users may get signed out once. - Expo client storage is now async. That only affects the native app when it upgrades its own client: `rollnote-app` stays on its 1.6.11 client, which talks to a 1.7 server fine. ## Tests - `pnpm install --frozen-lockfile`: ok - `pnpm lint`: ok - `pnpm exec tsc --noEmit -p tsconfig.json`: ok - `pnpm build`: ok - `pnpm openapi:generate`: no drift in `openapi.json` - `pnpm test` (unit): 31 suites, 302 tests passed - `pnpm test:int` (testcontainers on rootless podman, Postgres 18 + Mailpit, all migrations including 0031): 34 suites, 333 tests passed - `.pnpmfile.cjs` still keeps drizzle-kit, tsx and esbuild out of the prod dependency tree (`pnpm ls --prod` finds none). I didn't run the Docker image build or a Grype scan locally. CI runs the Docker build. ## To check after deploy 1. Boot logs: migration 0031 applied, and no `[Better Auth]: Drizzle schema mismatch` line. 2. Password login (username + password), sign-up email verification, and password reset. 3. 2FA: sign in on an account with TOTP enabled, then with the email OTP, then with a backup code. Also enable 2FA on a fresh account and check the QR/URI shows up. 4. Passkey: register one, then sign in with it. 5. Native app (Expo): sign in and confirm the session survives an app restart. 6. ZIP export/import: not auth-related (integration tests pass), but a quick import on a test account doesn't hurt. 7. Re-run Grype on the new image. The better-auth findings should be gone.
Upgrade better-auth, @better-auth/passkey and @better-auth/expo from
1.6.33 to 1.7.14 in lockstep. This fixes the advisories pnpm audit and
Grype report against 1.6.33:

- GHSA-965c-763c-88jm (critical): OAuth state usable as a Magic Link
- GHSA-r4xp-prcw-77qf (high): OAuth Proxy sign-in as another user
- GHSA-q84f-53jg-9ppm (high): device login approval does not show the
  requesting client
- GHSA-44jh-23m7-hpcf (low): concurrent requests exceed DB-backed rate
  limits (drizzle/kysely adapters)

Migration 0031 adds two_factor.failed_verification_count and
two_factor.locked_until. The twoFactor plugin has declared these lockout
columns since late 1.6 but our Drizzle schema never got them. 1.7 now
validates the Drizzle schema at boot and rejects every auth request on a
mismatch (password sign-in included), so the columns are required. Both
are nullable/defaulted, so the migration is additive and safe on a live
table.

Between 1.6.33 and 1.7.14 the core and plugin schemas for what we use
(username, twoFactor, passkey, bearer, expo) did not change: getAuthTables
returns the same fields in both versions (the lockout fields are already
in 1.6.33's). The 1.7.0 account issuer column was reverted in 1.7.3,
so no account backfill is needed.
eliott merged commit eae3be6b09 into main 2026-10-28 20:41:28 +01:00
eliott deleted branch security/better-auth-1.7 2026-10-28 20:41:28 +01:00
Sign in to join this conversation.
No reviewers
No labels
run-ci
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
RollNote/rollnote-api!75
No description provided.