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.
This commit is contained in:
Bereket Engida
2026-09-30 07:42:05 -07:00
parent 4186e36d15
commit 71a9d682c1
3 changed files with 74 additions and 3 deletions
@@ -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.
@@ -444,9 +444,19 @@ export const magicLink = (options: MagicLinkOptions) => {
user = promotedUser;
}
const session = await ctx.context.internalAdapter.createSession(
user.id,
);
let session: Awaited<
ReturnType<typeof ctx.context.internalAdapter.createSession>
> | 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");
@@ -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 = {