[GH-ISSUE #7406] What is the correct way to access what domain is requesting my server, so i can dynamically build error urls, magic links etc. #10803

Open
opened 2026-04-13 07:09:50 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @rayyan-buildin2 on GitHub (Jan 16, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7406

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

I have a case where multiple web apps can hit the server
1: localhost:5173
2: localhost:3001
3: ....any N

but i don't know how to redirect properly
most of the time it some how works when sending callback url but in some cases like banned user, even tough callback url is there it still shows server url localhost:3000

full url: http://localhost:3000/api/auth/magic-link/verify?token=XqiQKPYSlBCKHOqKJRnnqYZRXakqxIHb&callbackURL=http%3A%2F%2Flocalhost%3A5173%2F&errorCallbackURL=http%3A%2F%2Flocalhost%3A5173%2Fsign-in%3Ferror%3Dauthentication_failed

Current vs. Expected behavior

need to be consistent or allow we to access who requested it.

earlier i fixed this issue my a hook (in example) but because of many web apps requesting i cant use hardcoded web-app-url

What version of Better Auth are you using?

1.4.13

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:55 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T8103",
    "release": "25.2.0",
    "cpuCount": 8,
    "cpuModel": "Apple M1",
    "totalMemory": "8.00 GB",
    "freeMemory": "0.15 GB"
  },
  "node": {
    "version": "v24.11.1",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.24.0"
  },
  "frameworks": [
    {
      "name": "hono",
      "version": "catalog:"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "Unknown",
    "config": null
  }
}

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { admin, createAuthMiddleware, magicLink, organization } from "better-auth/plugins";
import { APIError } from "better-auth/api";
import { client, User, Organization, Member, Invitation } from "@packages/db";
import { authEnv } from "@packages/config/packages/auth.env";
import { serverEnv, IS_PROD } from "@packages/config/server.env";
import { ORGANIZATION_MAX_MEMBERS, ORGANIZATION_MAX_PENDING_INVITATIONS, ORGANIZATION_MAX_LIMIT_PER_USER, ORGANIZATION_UI_TERM, ORGANIZATION_UI_PLURAL_TERM } from "@packages/config/constants";
import { sendMagicLinkEmail } from "@packages/mail/magic-link";
import { sendEmailVerificationEmail } from "@packages/mail/verification";
import { sendOrganizationInvitation } from "@packages/mail/organization-invitation";
import { logger } from "@packages/logger";
import { generateShortId } from "@packages/utils/random.util";
import { extractNameFromEmail } from "./utils";

export const auth = betterAuth({
	database: mongodbAdapter(client),
	secret: authEnv.BETTER_AUTH_SECRET,
	baseURL: serverEnv.APP_URL,
	trustedOrigins: [authEnv.WEB_APP_URL],
	user: {
		// Track if user completed onboarding flow
		additionalFields: {
			isOnboardingComplete: {
				type: "boolean",
				defaultValue: false,
				required: false,
			},
		},
	},
	account: {
		// Allow linking multiple auth providers to same account
		accountLinking: {
			enabled: true,
			allowDifferentEmails: true,
			allowUnlinkingAll: true  // fine since we are using magic link
		},
	},
	plugins: [
		admin({
			defaultRole: "user",
			adminRoles: ["admin"],
		}),
		magicLink({
			sendMagicLink: async ({ email, url }) => {
				try {
					await sendMagicLinkEmail(email, {
						magicLinkUrl: url,
					});
				} catch (error) {
					logger.error(error, "Auth", `Failed to send magic link to ${email}`);
				}
			},
		}),
		organization({
			organizationLimit: ORGANIZATION_MAX_LIMIT_PER_USER,
			membershipLimit: ORGANIZATION_MAX_MEMBERS,
			organizationHooks: {
				// Enforce pending invitation limit before creating new invitation
				beforeCreateInvitation: async ({ invitation, organization }) => {
					const pendingCount = await Invitation.countDocuments({
						organizationId: organization.id,
						status: "pending",
					});

					if (pendingCount >= ORGANIZATION_MAX_PENDING_INVITATIONS) {
						throw new APIError("BAD_REQUEST", {
							message: `Maximum of ${ORGANIZATION_MAX_PENDING_INVITATIONS} pending invitations allowed per ${ORGANIZATION_UI_TERM}`,
						});
					}

					return { data: invitation };
				},
			},
			async sendInvitationEmail(data) {
				try {
					const inviteLink = `${authEnv.WEB_APP_URL}/organization/invitation/${data.id}`;
					await sendOrganizationInvitation({
						email: data.email,
						invitedByUsername: data.inviter.user.name,
						invitedByEmail: data.inviter.user.email,
						organizationName: data.organization.name,
						inviteLink,
					});
					logger.info(`${ORGANIZATION_UI_PLURAL_TERM} invitation sent to ${data.email} for ${data.organization.name}`, "Auth");
				} catch (error) {
					logger.error(error, "Auth", `Failed to send ${ORGANIZATION_UI_PLURAL_TERM} invitation to ${data.email}`);
				}
			},
		}),
	],
	emailVerification: {
		sendOnSignUp: true,
		autoSignInAfterVerification: true,
		sendVerificationEmail: async ({ user, url }) => {
			await sendEmailVerificationEmail(user.email, {
				userName: user.name,
				verificationUrl: url,
			});
		},
	},
	session: {
		expiresIn: 60 * 60 * 24 * 7, // 7 days
		updateAge: 60 * 60 * 24, // 1 day
	},
	advanced: {
		defaultCookieAttributes: {
			sameSite: "lax",
			secure: IS_PROD,
			httpOnly: true,
		},
	},
	socialProviders: {
		google: {
			prompt: "select_account consent",
			clientId: authEnv.GOOGLE_CLIENT_ID,
			clientSecret: authEnv.GOOGLE_CLIENT_SECRET,
		},
	},
	databaseHooks: {
		user: {
			create: {
				// Set default name from email if missing
				before: async (user) => {
					if (!user.name || user.name.trim() === "") {
						return {
							data: {
								...user,
								name: extractNameFromEmail(user.email),
							},
						};
					}
					return { data: user };
				},
				// Create default organization for new user
				after: async (user) => {
					try {
						const slug = generateShortId(12);
						const newOrg = await Organization.create({
							name: `My ${ORGANIZATION_UI_TERM}`,
							slug,
						});

						await Member.create({
							userId: user.id,
							organizationId: newOrg._id.toString(),
							role: "owner",
						});

						logger.info(`Created default ${ORGANIZATION_UI_TERM} for user ${user.email}`, "Auth");
					} catch (error) {
						logger.error(error, "Auth", `Failed to create default ${ORGANIZATION_UI_TERM} for user ${user.email}`);
						// Don't throw - let user creation succeed even if org creation fails
					}
				},
			},
		},
		session: {
			create: {
				// Auto-set latest organization as active on login
				before: async (session) => {
					try {
						const member = await Member.findOne({
							userId: session.userId,
						}).sort({ createdAt: -1 });

						if (member?.organizationId) {
							return {
								data: {
									...session,
									activeOrganizationId: member.organizationId.toString(),
								},
							};
						}
					} catch (error) {
						logger.error(error, "Auth", `Failed to set active ${ORGANIZATION_UI_TERM} on session creation`);
					}

					return { data: session };
				},
			},
		},
	},
	hooks: {
		// Handle auth errors and redirect with appropriate messages
		before: createAuthMiddleware(async (ctx) => {
			if (ctx.path === "/error") {
				const { error } = ctx.query as { error?: string };
				logger.error(error, "Auth", "Authentication error");

				const baseUrl = `${authEnv.WEB_APP_URL}/?error=${encodeURIComponent("Authentication Error")}&error_description=${encodeURIComponent("Failed to authenticate")}`;
				const redirectUrl = error === "banned" 
					? `${authEnv.WEB_APP_URL}/?error=${encodeURIComponent("You have been banned")}&error_description=${encodeURIComponent("Contact support team")}`
					: baseUrl;

				throw ctx.redirect(redirectUrl);
			}
		}),
	},
});

export type Session = typeof auth.$Infer.Session.session;
export type User = typeof auth.$Infer.Session.user;

Additional context

No response

Originally created by @rayyan-buildin2 on GitHub (Jan 16, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7406 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce I have a case where multiple web apps can hit the server 1: localhost:5173 2: localhost:3001 3: ....any N but i don't know how to redirect properly most of the time it some how works when sending callback url but in some cases like banned user, even tough callback url is there it still shows server url localhost:3000 full url: http://localhost:3000/api/auth/magic-link/verify?token=XqiQKPYSlBCKHOqKJRnnqYZRXakqxIHb&callbackURL=http%3A%2F%2Flocalhost%3A5173%2F&errorCallbackURL=http%3A%2F%2Flocalhost%3A5173%2Fsign-in%3Ferror%3Dauthentication_failed ### Current vs. Expected behavior need to be consistent or allow we to access who requested it. earlier i fixed this issue my a hook (in example) but because of many web apps requesting i cant use hardcoded web-app-url ### What version of Better Auth are you using? 1.4.13 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:55 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T8103", "release": "25.2.0", "cpuCount": 8, "cpuModel": "Apple M1", "totalMemory": "8.00 GB", "freeMemory": "0.15 GB" }, "node": { "version": "v24.11.1", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.24.0" }, "frameworks": [ { "name": "hono", "version": "catalog:" } ], "databases": null, "betterAuth": { "version": "Unknown", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth"; import { mongodbAdapter } from "better-auth/adapters/mongodb"; import { admin, createAuthMiddleware, magicLink, organization } from "better-auth/plugins"; import { APIError } from "better-auth/api"; import { client, User, Organization, Member, Invitation } from "@packages/db"; import { authEnv } from "@packages/config/packages/auth.env"; import { serverEnv, IS_PROD } from "@packages/config/server.env"; import { ORGANIZATION_MAX_MEMBERS, ORGANIZATION_MAX_PENDING_INVITATIONS, ORGANIZATION_MAX_LIMIT_PER_USER, ORGANIZATION_UI_TERM, ORGANIZATION_UI_PLURAL_TERM } from "@packages/config/constants"; import { sendMagicLinkEmail } from "@packages/mail/magic-link"; import { sendEmailVerificationEmail } from "@packages/mail/verification"; import { sendOrganizationInvitation } from "@packages/mail/organization-invitation"; import { logger } from "@packages/logger"; import { generateShortId } from "@packages/utils/random.util"; import { extractNameFromEmail } from "./utils"; export const auth = betterAuth({ database: mongodbAdapter(client), secret: authEnv.BETTER_AUTH_SECRET, baseURL: serverEnv.APP_URL, trustedOrigins: [authEnv.WEB_APP_URL], user: { // Track if user completed onboarding flow additionalFields: { isOnboardingComplete: { type: "boolean", defaultValue: false, required: false, }, }, }, account: { // Allow linking multiple auth providers to same account accountLinking: { enabled: true, allowDifferentEmails: true, allowUnlinkingAll: true // fine since we are using magic link }, }, plugins: [ admin({ defaultRole: "user", adminRoles: ["admin"], }), magicLink({ sendMagicLink: async ({ email, url }) => { try { await sendMagicLinkEmail(email, { magicLinkUrl: url, }); } catch (error) { logger.error(error, "Auth", `Failed to send magic link to ${email}`); } }, }), organization({ organizationLimit: ORGANIZATION_MAX_LIMIT_PER_USER, membershipLimit: ORGANIZATION_MAX_MEMBERS, organizationHooks: { // Enforce pending invitation limit before creating new invitation beforeCreateInvitation: async ({ invitation, organization }) => { const pendingCount = await Invitation.countDocuments({ organizationId: organization.id, status: "pending", }); if (pendingCount >= ORGANIZATION_MAX_PENDING_INVITATIONS) { throw new APIError("BAD_REQUEST", { message: `Maximum of ${ORGANIZATION_MAX_PENDING_INVITATIONS} pending invitations allowed per ${ORGANIZATION_UI_TERM}`, }); } return { data: invitation }; }, }, async sendInvitationEmail(data) { try { const inviteLink = `${authEnv.WEB_APP_URL}/organization/invitation/${data.id}`; await sendOrganizationInvitation({ email: data.email, invitedByUsername: data.inviter.user.name, invitedByEmail: data.inviter.user.email, organizationName: data.organization.name, inviteLink, }); logger.info(`${ORGANIZATION_UI_PLURAL_TERM} invitation sent to ${data.email} for ${data.organization.name}`, "Auth"); } catch (error) { logger.error(error, "Auth", `Failed to send ${ORGANIZATION_UI_PLURAL_TERM} invitation to ${data.email}`); } }, }), ], emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { await sendEmailVerificationEmail(user.email, { userName: user.name, verificationUrl: url, }); }, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // 1 day }, advanced: { defaultCookieAttributes: { sameSite: "lax", secure: IS_PROD, httpOnly: true, }, }, socialProviders: { google: { prompt: "select_account consent", clientId: authEnv.GOOGLE_CLIENT_ID, clientSecret: authEnv.GOOGLE_CLIENT_SECRET, }, }, databaseHooks: { user: { create: { // Set default name from email if missing before: async (user) => { if (!user.name || user.name.trim() === "") { return { data: { ...user, name: extractNameFromEmail(user.email), }, }; } return { data: user }; }, // Create default organization for new user after: async (user) => { try { const slug = generateShortId(12); const newOrg = await Organization.create({ name: `My ${ORGANIZATION_UI_TERM}`, slug, }); await Member.create({ userId: user.id, organizationId: newOrg._id.toString(), role: "owner", }); logger.info(`Created default ${ORGANIZATION_UI_TERM} for user ${user.email}`, "Auth"); } catch (error) { logger.error(error, "Auth", `Failed to create default ${ORGANIZATION_UI_TERM} for user ${user.email}`); // Don't throw - let user creation succeed even if org creation fails } }, }, }, session: { create: { // Auto-set latest organization as active on login before: async (session) => { try { const member = await Member.findOne({ userId: session.userId, }).sort({ createdAt: -1 }); if (member?.organizationId) { return { data: { ...session, activeOrganizationId: member.organizationId.toString(), }, }; } } catch (error) { logger.error(error, "Auth", `Failed to set active ${ORGANIZATION_UI_TERM} on session creation`); } return { data: session }; }, }, }, }, hooks: { // Handle auth errors and redirect with appropriate messages before: createAuthMiddleware(async (ctx) => { if (ctx.path === "/error") { const { error } = ctx.query as { error?: string }; logger.error(error, "Auth", "Authentication error"); const baseUrl = `${authEnv.WEB_APP_URL}/?error=${encodeURIComponent("Authentication Error")}&error_description=${encodeURIComponent("Failed to authenticate")}`; const redirectUrl = error === "banned" ? `${authEnv.WEB_APP_URL}/?error=${encodeURIComponent("You have been banned")}&error_description=${encodeURIComponent("Contact support team")}` : baseUrl; throw ctx.redirect(redirectUrl); } }), }, }); export type Session = typeof auth.$Infer.Session.session; export type User = typeof auth.$Infer.Session.user; ``` ### Additional context _No response_
GiteaMirror added the corecredentials labels 2026-04-13 07:09:50 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 16, 2026):

To dynamically build error URLs and handle different client requests when using Better Auth, you can access the callbackURL and errorCallbackURL parameters through the magic link function in your authentication logic. This allows you to redirect users correctly based on the requesting domain.

It’s recommended to validate the callbackURL and errorCallbackURL provided in the request to ensure that they match your trusted origins to prevent open redirect vulnerabilities. You can define trusted origins in your Better Auth configuration to allow only certain domains.

Here’s an approach you can take:

  1. Utilize the callbackURL and errorCallbackURL values in your magic link logic. If an error occurs, check if the errorCallbackURL exists and redirect the user accordingly.
  2. Ensure that your server-side logic checks the origins of incoming requests to dynamically determine which web application is making the request.
// Example hook to handle errors and redirects
hooks: {
    before: createAuthMiddleware(async (ctx) => {
        // Access the error and callback URLs from the query
        const { error, callbackURL, errorCallbackURL } = ctx.query;
        const trustedOrigins = [ /* your trusted origins here */ ];

        // Validate callback URLs against trusted origins
        if (!trustedOrigins.includes(callbackURL) && !trustedOrigins.includes(errorCallbackURL)) {
            // Handle untrusted URLs
            throw ctx.redirect('/error'); // or any secure fallback
        }

        // Handle authentication errors
        if (error) {
            const redirectUrl = error === 'banned' 
                ? `${errorCallbackURL}?message=You have been banned`
                : `${callbackURL}?error=authentication_failed`;
            throw ctx.redirect(redirectUrl);
        }
    }),
},

This will ensure that you can manage redirects correctly based on the client making the request. If you have specific cases in mind or further questions about the implementation, please let me know!

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3758224862 --> @better-auth-agent[bot] commented on GitHub (Jan 16, 2026): To dynamically build error URLs and handle different client requests when using Better Auth, you can access the `callbackURL` and `errorCallbackURL` parameters through the magic link function in your authentication logic. This allows you to redirect users correctly based on the requesting domain. It’s recommended to validate the `callbackURL` and `errorCallbackURL` provided in the request to ensure that they match your trusted origins to prevent open redirect vulnerabilities. You can define trusted origins in your Better Auth configuration to allow only certain domains. Here’s an approach you can take: 1. Utilize the `callbackURL` and `errorCallbackURL` values in your magic link logic. If an error occurs, check if the `errorCallbackURL` exists and redirect the user accordingly. 2. Ensure that your server-side logic checks the origins of incoming requests to dynamically determine which web application is making the request. ```typescript // Example hook to handle errors and redirects hooks: { before: createAuthMiddleware(async (ctx) => { // Access the error and callback URLs from the query const { error, callbackURL, errorCallbackURL } = ctx.query; const trustedOrigins = [ /* your trusted origins here */ ]; // Validate callback URLs against trusted origins if (!trustedOrigins.includes(callbackURL) && !trustedOrigins.includes(errorCallbackURL)) { // Handle untrusted URLs throw ctx.redirect('/error'); // or any secure fallback } // Handle authentication errors if (error) { const redirectUrl = error === 'banned' ? `${errorCallbackURL}?message=You have been banned` : `${callbackURL}?error=authentication_failed`; throw ctx.redirect(redirectUrl); } }), }, ``` This will ensure that you can manage redirects correctly based on the client making the request. If you have specific cases in mind or further questions about the implementation, please let me know! _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@rayyan-buildin2 commented on GitHub (Jan 16, 2026):

I am not just talking about ban but also in general

<!-- gh-comment-id:3758231907 --> @rayyan-buildin2 commented on GitHub (Jan 16, 2026): I am not just talking about ban but also in general
Author
Owner

@rayyan-buildin2 commented on GitHub (Jan 16, 2026):

Also in magic link it doest not respect the error url why?

callbackURL http://localhost:5173/
sign-in.tsx:46 errorCallbackURL http://localhost:5173/sign-in?error=authentication_failed

link sent in main: http://localhost:3000/api/auth/magic-link/verify?token=kAUxNHCLxiTylqAbwoYUCBvNfbrUkBms&callbackURL=http%3A%2F%2Flocalhost%3A5173%2F&errorCallbackURL=http%3A%2F%2Flocalhost%3A5173%2Fsign-in%3Ferror%3Dauthentication_failed

never redirect keep staying at server url localhost:3000

Image
<!-- gh-comment-id:3758468524 --> @rayyan-buildin2 commented on GitHub (Jan 16, 2026): Also in magic link it doest not respect the error url why? callbackURL http://localhost:5173/ sign-in.tsx:46 errorCallbackURL http://localhost:5173/sign-in?error=authentication_failed link sent in main: http://localhost:3000/api/auth/magic-link/verify?token=kAUxNHCLxiTylqAbwoYUCBvNfbrUkBms&callbackURL=http%3A%2F%2Flocalhost%3A5173%2F&errorCallbackURL=http%3A%2F%2Flocalhost%3A5173%2Fsign-in%3Ferror%3Dauthentication_failed never redirect keep staying at server url localhost:3000 <img width="1288" height="94" alt="Image" src="https://github.com/user-attachments/assets/ff28ba51-2afa-4ef5-ae97-61648726ede9" />
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10803