From 71a9d682c1fe72fe697cecc6acea1d7218039277 Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:42:05 -0700 Subject: [PATCH] fix(magic-link): redirect to errorCallbackURL on session creation errors The verify endpoint already forwards an APIError from a rejected user.create.before hook to errorCallbackURL, but the createSession call right after it was unguarded. A session.create.before hook that throws an APIError (e.g. the admin plugin's BANNED_USER) escaped as a raw error response instead of redirecting, unlike the createUser path a few lines above. Wrap createSession in the same try/catch pattern used for createUser. --- ...gic-link-verify-redirect-session-errors.md | 5 ++ .../src/plugins/magic-link/index.ts | 16 +++++- .../src/plugins/magic-link/magic-link.test.ts | 56 +++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 .changeset/magic-link-verify-redirect-session-errors.md diff --git a/.changeset/magic-link-verify-redirect-session-errors.md b/.changeset/magic-link-verify-redirect-session-errors.md new file mode 100644 index 0000000000..94d2c2fe2b --- /dev/null +++ b/.changeset/magic-link-verify-redirect-session-errors.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Fix `magic-link` verify redirecting to `errorCallbackURL` when a `session.create.before` hook rejects with an `APIError` (e.g. the admin plugin's `BANNED_USER`), instead of letting the raw error response escape the auth server. diff --git a/packages/better-auth/src/plugins/magic-link/index.ts b/packages/better-auth/src/plugins/magic-link/index.ts index 96f7170235..9d964475ff 100644 --- a/packages/better-auth/src/plugins/magic-link/index.ts +++ b/packages/better-auth/src/plugins/magic-link/index.ts @@ -444,9 +444,19 @@ export const magicLink = (options: MagicLinkOptions) => { user = promotedUser; } - const session = await ctx.context.internalAdapter.createSession( - user.id, - ); + let session: Awaited< + ReturnType + > | null; + try { + session = await ctx.context.internalAdapter.createSession(user.id); + } catch (e) { + // Browser flow: forward a gate rejection's code to the error + // URL instead of surfacing a raw API error. + if (isAPIError(e) && e.body?.code) { + redirectWithError(e.body.code, e.body.message); + } + throw e; + } if (!session) { redirectWithError("failed_to_create_session"); diff --git a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts index 1ddb93ea23..685798d6a1 100644 --- a/packages/better-auth/src/plugins/magic-link/magic-link.test.ts +++ b/packages/better-auth/src/plugins/magic-link/magic-link.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createAuthClient } from "../../client"; import { getTestInstance } from "../../test-utils/test-instance"; +import { admin } from "../admin"; import { magicLink } from "."; import { magicLinkClient } from "./client"; import { defaultKeyHasher } from "./utils"; @@ -604,6 +605,61 @@ describe("magic link verify origin validation", async () => { }); }); +/** + * A `session.create.before` hook that rejects with an `APIError` (e.g. the + * admin plugin banning the user) must be forwarded to `errorCallbackURL` the + * same way a `user.create.before` rejection already is on this endpoint, + * instead of escaping verify as a raw error response from the auth server. + * + * @see https://github.com/better-auth/better-auth/issues/7406 + */ +describe("magic link verify session creation errors", async () => { + it("redirects to errorCallbackURL when session creation is rejected", async () => { + let verificationEmail: VerificationEmail = { + email: "", + token: "", + url: "", + }; + const { auth, testUser, db } = await getTestInstance({ + trustedOrigins: ["http://localhost:5173"], + plugins: [ + admin(), + magicLink({ + async sendMagicLink(data) { + verificationEmail = data; + }, + }), + ], + }); + + const user = await db.findOne<{ id: string }>({ + model: "user", + where: [{ field: "email", value: testUser.email }], + }); + await db.update({ + model: "user", + where: [{ field: "id", value: user!.id }], + update: { banned: true }, + }); + + await auth.api.signInMagicLink({ + body: { + email: testUser.email, + callbackURL: "http://localhost:5173/", + errorCallbackURL: "http://localhost:5173/sign-in", + }, + headers: new Headers(), + }); + + const res = await auth.handler(new Request(verificationEmail.url)); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain( + "http://localhost:5173/sign-in", + ); + expect(res.headers.get("location")).toContain("error=BANNED_USER"); + }); +}); + describe("magic link storeToken", async () => { it("should store token in hashed", async () => { let verificationEmail: VerificationEmail = {