Skip to content

feat(auth): replace Supabase Auth with self-hosted Better Auth - #24

Merged
AlaskanTuna merged 2 commits into
mainfrom
feat/better-auth
Jul 30, 2026
Merged

feat(auth): replace Supabase Auth with self-hosted Better Auth#24
AlaskanTuna merged 2 commits into
mainfrom
feat/better-auth

Conversation

@AlaskanTuna

@AlaskanTuna AlaskanTuna commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Replaces Supabase Auth with self-hosted Better Auth, backed by the app's own Neon database. This removes the last third-party service that could pause underneath the product — the failure that took solarsim.tech down.

Implemented by two parallel Codex workers on disjoint file sets (backend seam / frontend client), with the shared surfaces (env schema, Prisma models, config/auth.ts) written up front to prevent collision.

What changed

  • backend/src/config/auth.ts (new) — Better Auth server config: email/password with requireEmailVerification: true (matching the retired Supabase enable_confirmations), Google OAuth, and account linking so an email user who later signs in with Google lands on the same account.
  • middleware/auth.tssupabase.auth.getUser(token)auth.api.getSession(...). The req.user = { id, email } contract is byte-identical, so no route handler changed.
  • app.ts — mounts app.all('/api/auth/*splat', toNodeHandler(auth)) before express.json() (Better Auth needs the raw body) and after CORS.
  • Frontend — new lib/auth-client.ts, lib/supabase.ts deleted. Auth is now cookie-based, so api/client.ts and useChat.ts drop bearer-token plumbing for credentials: 'include'.
  • SchemaUser/Session/Account/Verification added and migrated to Neon. tier folded onto User; the profiles table and the entire auth schema are dropped.
  • OAuth tokens encrypted at rest (encryptOAuthTokens: true) rather than Better Auth's plaintext default.

Fixes a defect introduced by the Neon migration

getUserTier read profiles through the retired Supabase client while ProjectQuotaUsage came from Neon — quota was split across two databases and every user silently resolved to FREE, throttling PRO users. profiles wasn't modelled in Prisma, which is why the earlier migration missed it. Tier now lives on the Better Auth user table and is read via Prisma.

Proven, not assumed: promoting the probe user to PRO in Neon flipped /api/quota from FREE/limit 5 to PRO/limit 20.

Preserved behaviours

  • The sign-out cache clear that fixed a cross-user project leak — both trigger paths (explicit sign-out and session loss) retained, with regression coverage.
  • The OAuth error-toast handler and URL-param scrubbing.
  • Cross-device locale sync — previously stored in Supabase user_metadata; now a locale field on the user, so the feature survives rather than being silently dropped.

Test plan

  • pnpm typecheck — exit 0
  • pnpm test — 216 frontend + 124 backend passing
  • eslint . --max-warnings 68 — exit 0 (61 warnings, down from 68)
  • prettier --check . — exit 0
  • Live against Neon: sign-up → tier: FREE; sign-in before verification → 403 EMAIL_NOT_VERIFIED; verification → emailVerified: true; sign-in → session cookie; protected route 401 without cookie / 200 with; probe user deleted afterwards
  • Data preserved throughout: Location 19, Project 20, TariffConfig 1

Not in this PR

  • Email is stubbed. emailService.ts logs verification/reset links instead of sending. Issue Send verification and password-reset email directly via Resend #7 wires Resend and ports the branded templates.
  • Google SSO is untested end-to-end — it needs <baseURL>/api/auth/callback/google registered in Google Cloud Console. Config is in place; the redirect URI is a manual step.

Refs #6

Summary by CodeRabbit

  • New Features
    • Added email/password authentication with required email verification and password reset support.
    • Added Google OAuth sign-in and account linking.
    • Added persistent user preferences for locale and subscription tier.
    • Improved session-based authentication across frontend and backend requests.
  • Bug Fixes
    • Authenticated requests now maintain login state more reliably through browser sessions.
    • User quota handling now reflects subscription tiers, including unlimited enterprise access.
  • Tests
    • Expanded coverage for authentication flows, OAuth errors, session handling, and tier-based quotas.

Moves identity into the app's own Neon database, removing the last
service that could pause underneath the product. Email/password with
mandatory verification plus Google OAuth, sessions via cookies rather
than bearer tokens.

Also fixes a cross-database defect the Neon migration introduced:
getUserTier read `profiles` through the retired Supabase client while
quota usage came from Neon, so every user silently resolved to FREE.
Tier now lives on the Better Auth user table and is read via Prisma.

Verified end to end against Neon: signup, pre-verification sign-in
rejected 403, verification, sign-in, and a protected route returning
401 without a cookie and 200 with one.

Refs #6
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AlaskanTuna, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c0fde29d-f210-4fd4-a888-9a66b22c42bf

📥 Commits

Reviewing files that changed from the base of the PR and between daa1b9f and f331f5b.

📒 Files selected for processing (2)
  • .env.example
  • RUNBOOK.md
📝 Walkthrough

Walkthrough

The pull request replaces Supabase Auth with Better Auth, adding Prisma-backed authentication tables, backend session handling, frontend cookie-authenticated requests, Google OAuth configuration, email verification and reset hooks, user locale persistence, and updated authentication and service tests.

Changes

Better Auth migration

Layer / File(s) Summary
Auth persistence and configuration
prisma/schema.prisma, prisma/migrations/..., backend/src/config/auth.ts, backend/src/config/env.ts, backend/package.json, frontend/package.json
Better Auth models, migrations, environment variables, dependencies, OAuth settings, email flows, account linking, and user fields are added.
Backend authentication and user services
backend/src/app.ts, backend/src/middleware/auth.ts, backend/src/services/*
The backend exposes Better Auth routes, validates session cookies, sends verification/reset callbacks, and reads user tiers through Prisma.
Frontend auth state and profile fields
frontend/src/lib/auth-client.ts, frontend/src/hooks/useAuth.tsx, frontend/src/hooks/useLocale.tsx, frontend/src/hooks/__tests__/*, frontend/src/pages/__tests__/*
Frontend authentication uses Better Auth sessions and actions, clears project cache on unauthenticated states, persists locale, and updates related tests and wording.
Cookie-authenticated API requests
frontend/src/api/client.ts, frontend/src/hooks/useChat.ts, frontend/src/components/chat/__tests__/*, frontend/vite.config.ts, frontend/src/lib/supabase.ts
API and chat requests send browser cookies instead of Supabase bearer tokens, while obsolete Supabase client code and test environment values are removed.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant authClient
  participant Backend
  participant BetterAuth
  participant Prisma
  Browser->>authClient: submit sign-in or sign-up
  authClient->>Backend: send request to /api/auth
  Backend->>BetterAuth: delegate authentication request
  BetterAuth->>Prisma: create or read user and session
  Prisma-->>BetterAuth: return authentication data
  BetterAuth-->>Browser: set session cookie and return session
  Browser->>Backend: send API request with credentials included
  Backend->>BetterAuth: get session from request headers
  BetterAuth-->>Backend: return session user
Loading

Possibly related issues

  • SolarSim issue 6 — This pull request implements the Supabase Auth replacement with Better Auth, including middleware, frontend flows, Prisma models, OAuth, and session handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: replacing Supabase Auth with self-hosted Better Auth.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/better-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/services/emailService.ts`:
- Around line 15-26: Update sendVerificationEmail and sendPasswordResetEmail to
deliver the links through the configured email provider instead of logging them.
Remove console.info calls and ensure neither the recipient address nor the URL
is written to production logs, while preserving each function’s existing
parameters and async behavior.

In `@frontend/src/hooks/useAuth.tsx`:
- Around line 96-99: Update signInWithGoogle to pass errorCallbackURL:
'/sign-in' alongside the existing Google provider and callbackURL options, then
update the related mock assertion to require this new option.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6fb538e8-9811-45b3-a67d-7cd895e60d47

📥 Commits

Reviewing files that changed from the base of the PR and between d256416 and daa1b9f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (25)
  • backend/package.json
  • backend/src/app.ts
  • backend/src/config/auth.ts
  • backend/src/config/env.ts
  • backend/src/middleware/auth.ts
  • backend/src/services/__tests__/userService.test.ts
  • backend/src/services/emailService.ts
  • backend/src/services/userService.ts
  • frontend/package.json
  • frontend/src/api/client.ts
  • frontend/src/components/auth/GoogleSignInButton.tsx
  • frontend/src/components/chat/__tests__/ChatLauncher.test.tsx
  • frontend/src/hooks/__tests__/useAuth.test.tsx
  • frontend/src/hooks/__tests__/useChat.test.tsx
  • frontend/src/hooks/useAuth.tsx
  • frontend/src/hooks/useChat.ts
  • frontend/src/hooks/useLocale.tsx
  • frontend/src/lib/auth-client.ts
  • frontend/src/lib/supabase.ts
  • frontend/src/pages/__tests__/SignInPage.test.tsx
  • frontend/src/pages/__tests__/SignUpPage.test.tsx
  • frontend/vite.config.ts
  • prisma/migrations/20260730081714_add_better_auth_tables/migration.sql
  • prisma/migrations/20260730083243_add_user_locale/migration.sql
  • prisma/schema.prisma
💤 Files with no reviewable changes (1)
  • frontend/src/lib/supabase.ts

Comment on lines +15 to +26
export async function sendVerificationEmail(email: string, url: string): Promise<void> {
console.info(`[Email] verification for ${email}: ${url}`)
}

/**
* Sends a password-reset link.
*
* @param email - Recipient address
* @param url - Better Auth password-reset link
*/
export async function sendPasswordResetEmail(email: string, url: string): Promise<void> {
console.info(`[Email] password reset for ${email}: ${url}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Replace token logging with real email delivery before enabling this flow.

requireEmailVerification makes new accounts unusable without delivery, while logging raw reset URLs exposes bearer credentials to anyone with log access and enables account takeover. Send these links through the email provider and never log the URL or recipient email in production.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 15-15: Avoid logging sensitive data
Context: console.info([Email] verification for ${email}: ${url})
Note: [CWE-532] Insertion of Sensitive Information into Log File.

(log-sensitive-data-typescript)


[warning] 25-25: Avoid logging sensitive data
Context: console.info([Email] password reset for ${email}: ${url})
Note: [CWE-532] Insertion of Sensitive Information into Log File.

(log-sensitive-data-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/services/emailService.ts` around lines 15 - 26, Update
sendVerificationEmail and sendPasswordResetEmail to deliver the links through
the configured email provider instead of logging them. Remove console.info calls
and ensure neither the recipient address nor the URL is written to production
logs, while preserving each function’s existing parameters and async behavior.

Source: Linters/SAST tools

Comment on lines 96 to 99
const signInWithGoogle = useCallback(async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${window.location.origin}/dashboard` }
})
return { error }
const { error } = await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' })
return { error: toAuthError(error) }
}, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does better-auth signIn.social redirect to the app's errorCallbackURL or its own default error page when errorCallbackURL is not provided?

💡 Result:

Yes, better-auth signIn.social redirects to a default error page when an error occurs and a specific errorCallbackURL is not provided [1][2]. By design, if you do not specify an errorCallbackURL, the authentication flow falls back to a default error page, which is typically located at /error relative to your application's base URL [3][2][4]. In scenarios where an errorCallbackURL is provided, the framework is intended to use that URL to redirect the user [5][4]. However, historically, there have been reported cases and complexities where the errorCallbackURL might be ignored—particularly if the OAuth state (which stores the callback information) is lost, expired, or fails validation before the redirect can occur [6][2]. Recent updates and patches have been introduced to improve this behavior, such as ensuring that the per-flow errorCallbackURL is honored even during certain types of state validation failures [6]. When the system cannot recover or identify an errorCallbackURL due to missing state, it reverts to the global default error page (or the URL configured via onAPIError.errorURL in your server-side auth configuration) [2][4][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
fd -a 'useAuth\.tsx|better-auth|auth' . | sed 's#^\./##' | head -100

echo "== locate authClient and sign in.social =="
rg -n "authClient|signInWithGoogle|errorCallbackURL|callbackURL|\\?error=|error_description|AuthProvider" .

echo "== useAuth excerpt if exists =="
if [ -f frontend/src/hooks/useAuth.tsx ]; then
  wc -l frontend/src/hooks/useAuth.tsx
  sed -n '1,140p' frontend/src/hooks/useAuth.tsx | cat -n
fi

echo "== package/deps mentioning better-auth =="
for f in package.json frontend/package.json pnpm-lock.yaml frontend/pnpm-lock.yaml; do
  [ -f "$f" ] && { echo "--- $f"; rg -n "better-auth|`@better-auth`" "$f" | head -50 || true; }
done

echo "== source files with authClient if any =="
rg -n "createAuthClient|betterAuth|signIn\.social\(" -S .

Repository: AlaskanTuna/SolarSim

Length of output: 15951


🌐 Web query:

better-auth signIn.social errorCallbackURL /api/auth/error OAuth error redirect default error page

💡 Result:

The errorCallbackURL parameter in better-auth is designed to redirect users to a custom URL when an OAuth authentication process fails [1][2]. However, users have frequently reported issues where this parameter is ignored, leading to a fallback to the library's default error page at /api/auth/error [3][4][5]. If you are encountering this issue, consider the following technical context and workarounds: 1. Use Absolute URLs: Ensure that your errorCallbackURL is a full, absolute URL (e.g., https://yourdomain.com/login) rather than a relative path [4]. 2. Configure Global Error Handling: As a fallback, configure the onAPIError.errorURL option in your server-side Better Auth initialization [4][6]. This sets a global destination for errors when a per-flow errorCallbackURL is missing or fails to be parsed [4][7]. typescript // Server-side auth config export const auth = betterAuth({ onAPIError: { errorURL: "/your-custom-error-page", }, }); 3. Known Limitations and Fixes: The handling of errorCallbackURL has undergone several updates to address race conditions and state parsing issues [8][9][10]. Ensure your version of better-auth is up to date, as recent PRs have focused on improving the consistency of redirecting to the per-flow URL instead of the default [8][10]. 4. Middleware Strategy: If inconsistencies persist, a common robust workaround is to use middleware (e.g., in Next.js) to intercept requests to /api/auth/error and perform a programmatic redirect to your desired custom error page [7]. The library is actively being improved to better support dynamic and per-flow error handling, including support for functions in errorURL configurations [10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== backend auth config =="
wc -l backend/src/config/auth.ts
cat -n backend/src/config/auth.ts

echo "== frontend auth client =="
cat -n frontend/src/lib/auth-client.ts

echo "== routes likely mounted at /sign-in =="
rg -n "Route\(|path=|sign-in|/auth|AuthProvider" frontend/src -S | head -120

Repository: AlaskanTuna/SolarSim

Length of output: 10471


🌐 Web query:

better-auth 1.6 signIn.social errorCallbackURL documentation

💡 Result:

In Better Auth 1.6, the signIn.social method accepts an errorCallbackURL parameter intended to define the redirect path if an error occurs during the social sign-in process [1][2][3]. Usage: You can pass errorCallbackURL as a property within the object provided to authClient.signIn.social: await authClient.signIn.social({ provider: "google", callbackURL: "/dashboard", errorCallbackURL: "/error", // Redirect if an error occurs newUserCallbackURL: "/welcome", }); Context and Known Limitations: While documented as a standard parameter, the handling of errorCallbackURL has been a subject of development and community discussion regarding its reliability in specific OAuth error scenarios [4][5][6]. 1. Intended Behavior: The parameter is designed to override the default error redirection behavior when a social sign-in flow fails [1][7]. 2. Global Configuration: If you need a fallback or a default behavior for all authentication errors, you can also configure the onAPIError option in your server-side auth initialization [8]: export const auth = betterAuth({ onAPIError: { errorURL: "/auth/error", // Global default error page }, }); 3. Technical Note: Developers have historically reported instances where OAuth provider-level errors (such as access_denied) might bypass the custom errorCallbackURL in favor of the global default error page [4][6]. Recent updates, such as those discussed in project pull requests, have aimed to ensure that errorCallbackURL is correctly parsed from the OAuth state to provide more consistent behavior [5]. If you find the parameter is not being respected in your specific implementation, ensure your version of better-auth is up to date, as this has been an area of active refinement [5].

Citations:


Redirect OAuth errors back to the app.

Without errorCallbackURL, signIn.social falls back to Better Auth’s default endpoint for sign-in errors, so Google errors won’t return to /sign-in where the URL-param toast/cleanup logic is mounted. Add an in-app error return URL, such as /sign-in, and update the related mock assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/hooks/useAuth.tsx` around lines 96 - 99, Update signInWithGoogle
to pass errorCallbackURL: '/sign-in' alongside the existing Google provider and
callbackURL options, then update the related mock assertion to require this new
option.

Neon's free tier scales compute to zero when idle. A cold start measured
5.4s, past Prisma's 5s default, so the first request after a quiet period
failed with "Can't reach database server" — reproduced as a 500 on the
Google sign-in path against a force-suspended compute.
@AlaskanTuna

Copy link
Copy Markdown
Owner Author

Google SSO verified ✅

The two redirect URIs are registered and working. Verified with a negative control so the result means something:

Redirect URI Google's response
http://localhost:3001/api/auth/callback/google (registered) 200 — real accounts.google.com/v3/signin/identifier page, continue= pointing at the OAuth consent step
https://solarsim.tech/api/auth/callback/google (registered) 200, no error
https://not-registered.example.com/cb (control) Error 400 · redirect_uri_mismatch

The control proves the test can detect a mismatch and doesn't fire for either real URI. Better Auth's generated request also carries PKCE (code_challenge_method=S256) and scope=email profile openid.

Full interactive login isn't verifiable from here — it needs real Google credentials — but every step up to the consent screen is confirmed.

Found and fixed a production bug while testing

The first SSO attempt returned 500:

PrismaClientInitializationError: Can't reach database server at
ep-icy-salad-av3ylj1s-pooler...neon.tech:5432

Not a fluke. Neon's free tier scales compute to zero when idle; the wake measured 5.4 s, past Prisma's 5 s default connect timeout. So the first user to hit the site after a quiet period gets a 500 on sign-in.

Reproduced deterministically by force-suspending the compute via the Neon API rather than waiting for idle:

  • Without connect_timeout: 500, cold compute
  • With connect_timeout=15: 200 in 2.83 s, from a confirmed idle endpoint

Fixed in f331f5b.env, .env.example, and RUNBOOK.md. This also explains why the earlier issue #4 idle test passed at 2.17 s: the cold start is variable and straddles the 5 s default.

Worth noting for issue #9: this interacts with the Neon CU-hour budget, so the Render cold-start mitigation still cannot be a database pinger.

@AlaskanTuna
AlaskanTuna merged commit e414201 into main Jul 30, 2026
3 checks passed
@AlaskanTuna
AlaskanTuna deleted the feat/better-auth branch July 30, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant