Skip to content

Commit c359c4f

Browse files
committed
Keep trusting localhost when BETTER_AUTH_URL is a tenant's host
The Visionaries setup doc told devs to set BETTER_AUTH_URL to the Visionaries host. Better Auth only trusted localhost because it was the base URL, so with that setting, signing in to the default tenant at localhost:3000 failed with 403 "Invalid origin" and the login page did nothing. Outside production, trust localhost regardless of the base URL. The doc now says to leave BETTER_AUTH_URL at the template value, since every local tenant works with it, and to change it only when Better Auth-built links such as the password reset link need to open on the Visionaries host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsE8hW2Sa81QXuqHAStNNa
1 parent 70b0529 commit c359c4f

3 files changed

Lines changed: 49 additions & 11 deletions

File tree

‎docs/VISIONARIES_DEVELOPMENT.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@ email configuration for ordinary development.
1212

1313
Add `visionariestothethrone.lvh.me` to the comma-separated `ALLOWED_DEV_ORIGINS`
1414
value in your local `.env` (preserve any existing entries). Restart Next.js after
15-
changing it. For this tenant's auth work, set:
15+
changing it.
16+
17+
Leave `BETTER_AUTH_URL` at the template's `http://localhost:3000`. Visionaries,
18+
ServiceStart at `localhost:3000`, and the other seeded tenants all work with it
19+
at the same time. Change it only when you need links that Better Auth builds,
20+
such as the password reset link, to open on the Visionaries host:
1621

1722
```dotenv
1823
BETTER_AUTH_URL=http://visionariestothethrone.lvh.me:3000
1924
```
2025

26+
`localhost:3000` keeps working with either value. Restart Next.js after changing
27+
it.
28+
2129
With your **local development database** configured, run:
2230

2331
```bash

‎lib/auth.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,28 @@ const baseURL = process.env.BETTER_AUTH_URL || getBaseUrl();
2828
* Tenants are served from subdomains of the tenant root domain (`<slug>.lvh.me`
2929
* in local development). Better Auth only trusts its base URL by default and
3030
* rejects cookie-bearing requests from other origins, so trust the tenant
31-
* hosts too. These are static patterns rather than a per-request function
32-
* because Better Auth stores per-request origins on a context shared by
33-
* concurrent requests.
31+
* hosts too. Outside production, also trust localhost, which serves the
32+
* default tenant locally, so it keeps working when BETTER_AUTH_URL points at
33+
* another tenant's host. These are static patterns rather than a per-request
34+
* function because Better Auth stores per-request origins on a context shared
35+
* by concurrent requests.
3436
*/
35-
function tenantTrustedOrigins(): string[] {
36-
const { protocol, port } = new URL(baseURL);
37+
export function tenantTrustedOrigins(authBaseURL: string): string[] {
38+
const { protocol, port } = new URL(authBaseURL);
3739
const suffix = port ? `:${port}` : "";
40+
const isProduction = process.env.NODE_ENV === "production";
3841
const roots = [getTenantRootDomain()];
39-
if (process.env.NODE_ENV !== "production" && !roots.includes("lvh.me")) {
42+
if (!isProduction && !roots.includes("lvh.me")) {
4043
roots.push("lvh.me");
4144
}
42-
return roots.flatMap((root) => [
45+
const origins = roots.flatMap((root) => [
4346
`${protocol}//${root}${suffix}`,
4447
`${protocol}//*.${root}${suffix}`,
4548
]);
49+
if (!isProduction) {
50+
origins.push(`${protocol}//localhost${suffix}`);
51+
}
52+
return origins;
4653
}
4754

4855
export const auth = betterAuth({
@@ -177,7 +184,7 @@ export const auth = betterAuth({
177184
},
178185
},
179186
baseURL,
180-
trustedOrigins: tenantTrustedOrigins(),
187+
trustedOrigins: tenantTrustedOrigins(baseURL),
181188
database: drizzleAdapter(db, {
182189
provider: "pg",
183190
usePlural: true,

‎tests/unit/lib/auth.test.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// @vitest-environment node
2-
import { describe, expect, it } from "vitest";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
33
import { and, eq } from "drizzle-orm";
44
import db from "@/lib/db";
5-
import { auth } from "@/lib/auth";
5+
import { auth, tenantTrustedOrigins } from "@/lib/auth";
66
import { eventRsvps, members, MemberType, shiftRSVPs } from "@/lib/schema";
77
import {
88
addMember,
@@ -207,6 +207,29 @@ describe("trusted origins", () => {
207207
});
208208
});
209209

210+
describe(tenantTrustedOrigins, () => {
211+
afterEach(() => {
212+
vi.unstubAllEnvs();
213+
});
214+
215+
it("keeps trusting localhost when the base URL is a tenant's host", () => {
216+
const origins = tenantTrustedOrigins(
217+
"http://visionariestothethrone.lvh.me:3000",
218+
);
219+
220+
expect(origins).toContain("http://localhost:3000");
221+
expect(origins).toContain("http://*.lvh.me:3000");
222+
});
223+
224+
it("doesn't trust localhost in production", () => {
225+
vi.stubEnv("NODE_ENV", "production");
226+
227+
const origins = tenantTrustedOrigins("https://acme.servicestart.com");
228+
229+
expect(origins.some((origin) => origin.includes("localhost"))).toBe(false);
230+
});
231+
});
232+
210233
describe("member type", () => {
211234
it("is returned with the member", async () => {
212235
const org = await createOrganization("member-type-read");

0 commit comments

Comments
 (0)