Last active
December 21, 2025 03:14
-
-
Save teyfix/1b0144d1c8b1c5d8e84174e90043346b to your computer and use it in GitHub Desktop.
Better Auth with dynamic baseURL workaround
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { serverConfig } from "@/config/server.config"; | |
| import { db } from "@/db/drizzle"; // your drizzle instance | |
| import * as schema from "@/db/schemas/auth.schema"; | |
| import { betterAuth, type BetterAuthOptions } from "better-auth"; | |
| import { drizzleAdapter } from "better-auth/adapters/drizzle"; | |
| import { nextCookies } from "better-auth/next-js"; | |
| import { admin } from "better-auth/plugins"; | |
| import { v7 } from "uuid"; | |
| /** | |
| * Type-safe wrapper for defining Better Auth configuration objects. | |
| * Provides type inference and validation without runtime overhead. | |
| * | |
| * @template Options - The Better Auth options type | |
| * @param options - Configuration options for Better Auth | |
| * @returns The same options object with proper typing | |
| */ | |
| const createConfig = <Options extends BetterAuthOptions>( | |
| options: Options, | |
| ): Options => options; | |
| /** | |
| * Default base URL for Better Auth endpoints. | |
| * Uses the first trusted origin from the server configuration. | |
| */ | |
| const [defaultBaseURL] = serverConfig.BETTER_AUTH_TRUSTED_ORIGINS; | |
| /** | |
| * Base configuration for Better Auth. | |
| * Contains all shared settings that will be used across all auth instances. | |
| * | |
| * Configuration includes: | |
| * - UUID v7 ID generation for better database performance | |
| * - Secure cookies for production environments | |
| * - PostgreSQL adapter with Drizzle ORM | |
| * - Google OAuth provider | |
| * - Admin plugin for user management | |
| * - Next.js cookie integration | |
| */ | |
| const baseAuthConfig = createConfig({ | |
| advanced: { | |
| database: { | |
| generateId: () => v7(), | |
| }, | |
| useSecureCookies: true, | |
| }, | |
| baseURL: defaultBaseURL, | |
| basePath: "/api/auth", | |
| database: drizzleAdapter(db, { | |
| provider: "pg", | |
| schema, | |
| transaction: false, | |
| }), | |
| emailAndPassword: { | |
| enabled: false, | |
| }, | |
| plugins: [ | |
| admin({ | |
| adminUserIds: serverConfig.BETTER_AUTH_ADMIN_USER_IDS, | |
| }), | |
| nextCookies(), | |
| ], | |
| secret: serverConfig.BETTER_AUTH_SECRET, | |
| socialProviders: { | |
| google: { | |
| clientId: serverConfig.GOOGLE_CLIENT_ID, | |
| clientSecret: serverConfig.GOOGLE_CLIENT_SECRET, | |
| }, | |
| }, | |
| trustedOrigins: serverConfig.BETTER_AUTH_TRUSTED_ORIGINS, | |
| }); | |
| /** | |
| * Type representing the base auth configuration. | |
| * Used for type inference when extending configuration. | |
| */ | |
| type BaseAuthConfig = typeof baseAuthConfig; | |
| /** | |
| * Extends the base auth configuration with additional options. | |
| * Allows overriding base configuration on a per-instance basis. | |
| * | |
| * @template Options - Additional options to merge with base config | |
| * @param options - Custom configuration options to extend base config | |
| * @returns Merged configuration combining base and custom options | |
| * | |
| * @example | |
| * const customAuth = extendAuthConfig({ | |
| * baseURL: "https://custom-domain.com", | |
| * }); | |
| */ | |
| const extendConfig = <Options extends BetterAuthOptions>( | |
| options: Options, | |
| ): BaseAuthConfig & Options => ({ ...baseAuthConfig, ...options }); | |
| /** | |
| * Creates a Better Auth instance with extended configuration. | |
| * Allows overriding base configuration on a per-instance basis. | |
| * | |
| * @param options - Additional options to merge with base configuration | |
| * @returns Configured Better Auth instance | |
| * | |
| * @example | |
| * // Create auth instance with custom baseURL | |
| * const auth = createAuth({ | |
| * baseURL: "https://example.com" | |
| * }); | |
| * | |
| * @example | |
| * // Create auth instance with default configuration | |
| * const auth = createAuth({}); | |
| */ | |
| export const createAuth = <Options extends BetterAuthOptions>( | |
| options: Options, | |
| ) => betterAuth(extendConfig(options)); | |
| /** | |
| * Default Better Auth instance using base configuration. | |
| * This is the primary auth instance used when no custom configuration is needed. | |
| * | |
| * For dynamic baseURL support (e.g., behind reverse proxies), create new instances | |
| * using `createAuth({ baseURL })` instead of using this default instance. | |
| */ | |
| export const auth = createAuth({}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Extracts the origin from proxy forwarded headers. | |
| * Used when the application is behind a reverse proxy (e.g., Traefik, nginx, Cloudflare). | |
| * | |
| * Constructs the origin from the following headers: | |
| * - `x-forwarded-proto`: The protocol (http/https) | |
| * - `x-forwarded-host`: The hostname | |
| * - `x-forwarded-port`: The port (defaults to 443 for https, 80 for http) | |
| * | |
| * @param request - The incoming request | |
| * @returns The reconstructed origin, or null if required headers are missing or invalid | |
| * | |
| * @example | |
| * // Request with forwarded headers | |
| * fromProxyHeader(request) // => "https://example.com" | |
| * | |
| * @example | |
| * // Request missing forwarded headers | |
| * fromProxyHeader(request) // => null | |
| */ | |
| const fromProxyHeader = (request: Request): string | null => { | |
| const headers = ["x-forwarded-proto", "x-forwarded-host", "x-forwarded-port"]; | |
| let [proto, host, port] = headers.map((header) => | |
| request.headers.get(header), | |
| ); | |
| // Both protocol and host are required | |
| if (proto == null || host == null) { | |
| return null; | |
| } | |
| // Use standard ports if not explicitly set | |
| if (port == null) { | |
| port = proto === "https" ? "443" : "80"; | |
| } | |
| try { | |
| return new URL(`${proto}://${host}:${port}`).origin; | |
| } catch { | |
| // Invalid URL construction (malformed headers) | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Extracts the origin from the `Origin` request header. | |
| * The Origin header is automatically sent by browsers in cross-origin requests (CORS). | |
| * | |
| * @param request - The incoming request | |
| * @returns The origin from the header, or null if not present | |
| * | |
| * @example | |
| * // CORS request from a browser | |
| * fromOriginHeader(request) // => "http://localhost:3000" | |
| * | |
| * @example | |
| * // Server-to-server request (no Origin header) | |
| * fromOriginHeader(request) // => null | |
| */ | |
| const fromOriginHeader = (request: Request): string | null => | |
| request.headers.get("origin"); | |
| /** | |
| * Extracts the origin from the request URL itself. | |
| * Used as a fallback when no proxy headers or Origin header are present. | |
| * | |
| * @param request - The incoming request | |
| * @returns The origin from the request URL, or null if URL is malformed | |
| * | |
| * @example | |
| * // Direct request to the server | |
| * fromURL(request) // => "http://localhost:3000" | |
| * | |
| * @example | |
| * // Malformed request URL | |
| * fromURL(request) // => null | |
| */ | |
| const fromURL = (request: Request): string | null => { | |
| try { | |
| return new URL(request.url).origin; | |
| } catch { | |
| // Malformed request URL | |
| return null; | |
| } | |
| }; | |
| /** | |
| * Extracts the origin from a request using multiple fallback strategies. | |
| * | |
| * Attempts to determine the origin in the following priority order: | |
| * 1. Proxy forwarded headers (x-forwarded-proto, x-forwarded-host, x-forwarded-port) | |
| * 2. Origin request header (from CORS requests) | |
| * 3. Request URL origin (direct requests) | |
| * | |
| * This approach ensures the correct origin is extracted regardless of whether the | |
| * application is behind a reverse proxy, receiving CORS requests, or handling direct requests. | |
| * | |
| * @param request - The incoming request | |
| * @returns The extracted origin, or null if none of the strategies succeed | |
| * | |
| * @example | |
| * // Behind reverse proxy with forwarded headers | |
| * extractOrigin(request) // => "https://example.com" | |
| * | |
| * @example | |
| * // Browser CORS request without proxy | |
| * extractOrigin(request) // => "http://localhost:3000" | |
| * | |
| * @example | |
| * // Direct local request | |
| * extractOrigin(request) // => "http://127.0.0.1:3000" | |
| */ | |
| export const extractOrigin = (request: Request): string | null => | |
| fromProxyHeader(request) || fromOriginHeader(request) || fromURL(request); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { serverConfig } from "@/config/server.config"; | |
| import { createAuth } from "@/lib/auth"; | |
| import { extractOrigin } from "@/lib/http/extract-origin"; | |
| import { toNextJsHandler } from "better-auth/next-js"; | |
| type HttpMethod = "GET" | "POST"; | |
| type RequestHandler = (request: Request) => Response | Promise<Response>; | |
| /** | |
| * Cache for Better Auth handlers, keyed by baseURL. | |
| * Each origin gets its own auth instance to support dynamic baseURL configuration. | |
| */ | |
| const handlerCache = new Map<string, ReturnType<typeof toNextJsHandler>>(); | |
| /** | |
| * List of allowed origins for Better Auth requests. | |
| * Requests from origins not in this list will be rejected. | |
| * | |
| * @example | |
| * - "http://localhost:3000" | |
| * - "https://example.com" | |
| * - "https://example.com:4000" | |
| */ | |
| const trustedOrigins = serverConfig.BETTER_AUTH_TRUSTED_ORIGINS; | |
| /** | |
| * Handles Better Auth requests with dynamic baseURL support. | |
| * | |
| * Creates and caches a separate auth handler for each allowed origin, | |
| * enabling the same auth instance to serve multiple domains correctly. | |
| * | |
| * @param method - The HTTP method (GET or POST) | |
| * @param request - The incoming Next.js request | |
| * @returns Response object from Better Auth or a 403 Forbidden error | |
| * | |
| * @throws Will return 403 if the request origin is not in the allowed list | |
| */ | |
| const handleRequest = async <T extends HttpMethod>( | |
| method: T, | |
| request: Request, | |
| ): Promise<Response> => { | |
| const origin = extractOrigin(request); | |
| if (!origin || !trustedOrigins.includes(origin)) { | |
| return Response.json( | |
| { message: "Unknown origin", origin }, | |
| { status: 403, statusText: "Forbidden" }, | |
| ); | |
| } | |
| let handler = handlerCache.get(origin); | |
| if (handler == null) { | |
| handler = toNextJsHandler(createAuth({ baseURL: origin })); | |
| handlerCache.set(origin, handler); | |
| } | |
| return handler[method](request); | |
| }; | |
| /** | |
| * Creates a route handler for the specified HTTP method. | |
| * | |
| * @param method - The HTTP method to handle | |
| * @returns A Next.js route handler function | |
| */ | |
| const createHandler = | |
| <T extends HttpMethod>(method: T): RequestHandler => | |
| (request: Request): Promise<Response> => | |
| handleRequest(method, request); | |
| export const GET = createHandler("GET"); | |
| export const POST = createHandler("POST"); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment