[GH-ISSUE #2174] Use a POST request for authentication links #17713

Closed
opened 2026-04-15 15:58:18 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @Immortalin on GitHub (Apr 8, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/2174

Is this suited for github?

  • Yes, this is suited for github

Authentication links such as login with a magic link relies on GET requests which may be inadvertently triggered by the email inbox crawler for most free hosted email services.

Describe the solution you'd like

Add an option to the magic link auth (and other parts of the auth system that uses with links) to redirect to a page which executes a POST request from the browser to ensure that it doesn't get triggered by accident.

Describe alternatives you've considered

N/A

Additional context

Many email providers automatically scrape embedded links for AI features, summary generation, do ad targeting etc.

Originally created by @Immortalin on GitHub (Apr 8, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/2174 ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. Authentication links such as login with a magic link relies on GET requests which may be inadvertently triggered by the email inbox crawler for most free hosted email services. ### Describe the solution you'd like Add an option to the magic link auth (and other parts of the auth system that uses with links) to redirect to a page which executes a POST request from the browser to ensure that it doesn't get triggered by accident. ### Describe alternatives you've considered N/A ### Additional context Many email providers automatically scrape embedded links for AI features, summary generation, do ad targeting etc.
GiteaMirror added the lockedenhancement labels 2026-04-15 15:58:18 -05:00
Author
Owner

@shansmith01 commented on GitHub (May 2, 2025):

This would be so good. I think i am having an issue with outlook users who can not login. Pretty sure this is related to Antivirus software.To get a round this I am making my own verification page and then calling magic link verify e.g

`import { useCallback, useEffect } from "react";
import { href, useNavigate, useSearchParams } from "react-router";
import { authClient } from "~/lib/auth.client";

export default function VerifyEmail() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");

if (!token) {
	return <div>No token</div>;
}

const navigate = useNavigate();

const verifyEmail = useCallback(async () => {
	const { data } = await authClient.magicLink.verify({
		query: {
			token,
		},
        fetchOptions: {
            redirect: "follow"
        }
	});		
	if (data?.user) {
		navigate("/");
	} else {
		navigate({
			pathname: href("/sign-in"),
			search: "?error=invalid-token",
		});
	}
}, [token, navigate]);

useEffect(() => {
	verifyEmail();
}, [verifyEmail]);

return <div>VerifyEmail Screen</div>;`

Testing in production now. Hope it works

<!-- gh-comment-id:2846162247 --> @shansmith01 commented on GitHub (May 2, 2025): This would be so good. I think i am having an issue with outlook users who can not login. Pretty sure this is related to Antivirus software.To get a round this I am making my own verification page and then calling magic link verify e.g `import { useCallback, useEffect } from "react"; import { href, useNavigate, useSearchParams } from "react-router"; import { authClient } from "~/lib/auth.client"; export default function VerifyEmail() { const [searchParams] = useSearchParams(); const token = searchParams.get("token"); if (!token) { return <div>No token</div>; } const navigate = useNavigate(); const verifyEmail = useCallback(async () => { const { data } = await authClient.magicLink.verify({ query: { token, }, fetchOptions: { redirect: "follow" } }); if (data?.user) { navigate("/"); } else { navigate({ pathname: href("/sign-in"), search: "?error=invalid-token", }); } }, [token, navigate]); useEffect(() => { verifyEmail(); }, [verifyEmail]); return <div>VerifyEmail Screen</div>;` Testing in production now. Hope it works
Author
Owner

@airtonix commented on GitHub (May 26, 2025):

This would be so good. I think i am having an issue with outlook users who can not login. Pretty sure this is related to Antivirus software.To get a round this I am making my own verification page and then calling magic link verify e.g

`import { useCallback, useEffect } from "react"; import { href, useNavigate, useSearchParams } from "react-router"; import { authClient } from "~/lib/auth.client";

export default function VerifyEmail() { const [searchParams] = useSearchParams(); const token = searchParams.get("token");

...

useEffect(() => {
	verifyEmail();
}, [verifyEmail]);

return <div>VerifyEmail Screen</div>;`

Testing in production now. Hope it works

This just looks like a page that renders as a result of a GET request.

Are you hoping that said email scrapers can't execute and render react apps?

Most of them can, so this won't solve your issue.

What I think you want to do is run verifyEmail when the user clicks a button on this page?

If it did solve your particular users escalated cases, then it just means that those people use an email service/email app that only uses primitive crawling (ie, curl/wget instead of playwright)

<!-- gh-comment-id:2908368429 --> @airtonix commented on GitHub (May 26, 2025): > This would be so good. I think i am having an issue with outlook users who can not login. Pretty sure this is related to Antivirus software.To get a round this I am making my own verification page and then calling magic link verify e.g > > `import { useCallback, useEffect } from "react"; import { href, useNavigate, useSearchParams } from "react-router"; import { authClient } from "~/lib/auth.client"; > > export default function VerifyEmail() { const [searchParams] = useSearchParams(); const token = searchParams.get("token"); > > ``` > ... > > useEffect(() => { > verifyEmail(); > }, [verifyEmail]); > > return <div>VerifyEmail Screen</div>;` > ``` > > Testing in production now. Hope it works This just looks like a page that renders as a result of a GET request. Are you hoping that said email scrapers can't execute and render react apps? Most of them can, so this won't solve your issue. What I think you want to do is run `verifyEmail` when the user clicks a button on this page? If it did solve your particular users escalated cases, then it just means that those people use an email service/email app that only uses primitive crawling (ie, curl/wget instead of playwright)
Author
Owner

@shansmith01 commented on GitHub (May 26, 2025):

The above worked some times, but the final solution i ended up with is to make the request api (get) request serverside and have the use click a button that was a post request to a backend...

Code example below is using react router 7 in framework mode if anyone is iunterested

import { Form, href, redirect, useNavigation } from "react-router";
import { redirectWithError } from "remix-toast";
import { z } from "zod";
import { type Route } from "./+types/verify-email";
import { Button } from "~/components/ui/button";

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "~/components/ui/card";
import { auth, callbackURLSchema } from "~/lib/auth.server";
import { logger } from "~/lib/logger.server";

// import { auth } from "~/lib/auth.server";

export async function loader({ request }: Route.LoaderArgs) {
  const searchParams = new URL(request.url).searchParams;
  const token = searchParams.get("token");
  const callbackURL = searchParams.get("callbackURL");
  if (!token) {
    logger.error("No token provided");
    return redirectWithError(href("/"), "No token provided");
  }
  return { token, callbackURL };
}

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const token = formData.get("token");
  const callbackURL = formData.get("callbackURL");
  try {
    const validation = z.object({
      token: z.string().min(1),
      callbackURL: callbackURLSchema,
    });

    const validated = validation.parse({ token, callbackURL });

    if (!validated.token) {
      return redirectWithError(href("/"), "Invalid token");
    }

    const response = await auth.api.magicLinkVerify({
      asResponse: true,
      returnHeaders: true,
      query: {
        token: validated.token,
      },
      headers: {
        "Content-Type": "application/json",
      },
    });

    const location = response.headers.get("location");

    if (response.ok) {
      const data = await response.json();
      logger.info(
        {
          userId: data.user.id,
          token: token,
        },
        "User verified email"
      );
      if (validated.callbackURL) {
        return redirect(validated.callbackURL, {
          headers: response.headers,
        });
      }
      return redirect(href("/"), {
        headers: response.headers,
      });
    }

    // The default behavior of the API is to redirect to the sign-in page with an error message. We want to intercept this and handle it here.
    if (location) {
      const error = new URL(location).searchParams.get("error");
      if (error) {
        if (error === "INVALID_TOKEN") {
          return redirectWithError(`${href("/")}?error=${error}`, {
            message: "Invalid token",
            description:
              "This link has likely already been used. Please sign in again.",
          });
        }
        if (error === "EXPIRED_TOKEN") {
          return redirectWithError(`${href("/")}?error=${error}`, {
            message: "Token expired",
            description: "This link has expired. Please sign in again.",
          });
        }
        return redirectWithError(`${href("/")}?error=${error}`, {
          message: "Invalid token",
          description: error,
        });
      }
    }

    return redirectWithError(
      href("/"),
      "Something went wrong verifying your email. Please try again."
    );
  } catch (_error) {
    throw new Error("Invalid token", { cause: _error });
  }
}

export default function VerifyEmail({ loaderData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const isSubmitting = navigation.state !== "idle";

  return (
    <div className="flex flex-col justify-center items-center h-screen gap-10">
      <Card className="max-w-md mx-auto min-w-md ">
        <>
          <CardHeader>
            <CardTitle className="text-lg md:text-xl">
              Confirm Your Login
            </CardTitle>
            <CardDescription className="text-xs md:text-sm text-pretty">
              Please click the button below to complete your login.
            </CardDescription>
          </CardHeader>
          <CardContent>
            <div className="grid gap-4">
              <Form method="post">
                <input
                  id="callbackURL"
                  name="callbackURL"
                  defaultValue={loaderData?.callbackURL ?? undefined}
                  hidden
                />
                <input
                  id="token"
                  name="token"
                  defaultValue={loaderData?.token}
                  hidden
                />
                <Button className="gap-2" type="submit" disabled={isSubmitting}>
                  {isSubmitting ? "Verifying..." : "Complete Login"}
                </Button>
              </Form>
            </div>
          </CardContent>
        </>
      </Card>
    </div>
  );
}
<!-- gh-comment-id:2908394293 --> @shansmith01 commented on GitHub (May 26, 2025): The above worked some times, but the final solution i ended up with is to make the request api (get) request serverside and have the use click a button that was a post request to a backend... Code example below is using react router 7 in framework mode if anyone is iunterested ``` import { Form, href, redirect, useNavigation } from "react-router"; import { redirectWithError } from "remix-toast"; import { z } from "zod"; import { type Route } from "./+types/verify-email"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card"; import { auth, callbackURLSchema } from "~/lib/auth.server"; import { logger } from "~/lib/logger.server"; // import { auth } from "~/lib/auth.server"; export async function loader({ request }: Route.LoaderArgs) { const searchParams = new URL(request.url).searchParams; const token = searchParams.get("token"); const callbackURL = searchParams.get("callbackURL"); if (!token) { logger.error("No token provided"); return redirectWithError(href("/"), "No token provided"); } return { token, callbackURL }; } export async function action({ request }: Route.ActionArgs) { const formData = await request.formData(); const token = formData.get("token"); const callbackURL = formData.get("callbackURL"); try { const validation = z.object({ token: z.string().min(1), callbackURL: callbackURLSchema, }); const validated = validation.parse({ token, callbackURL }); if (!validated.token) { return redirectWithError(href("/"), "Invalid token"); } const response = await auth.api.magicLinkVerify({ asResponse: true, returnHeaders: true, query: { token: validated.token, }, headers: { "Content-Type": "application/json", }, }); const location = response.headers.get("location"); if (response.ok) { const data = await response.json(); logger.info( { userId: data.user.id, token: token, }, "User verified email" ); if (validated.callbackURL) { return redirect(validated.callbackURL, { headers: response.headers, }); } return redirect(href("/"), { headers: response.headers, }); } // The default behavior of the API is to redirect to the sign-in page with an error message. We want to intercept this and handle it here. if (location) { const error = new URL(location).searchParams.get("error"); if (error) { if (error === "INVALID_TOKEN") { return redirectWithError(`${href("/")}?error=${error}`, { message: "Invalid token", description: "This link has likely already been used. Please sign in again.", }); } if (error === "EXPIRED_TOKEN") { return redirectWithError(`${href("/")}?error=${error}`, { message: "Token expired", description: "This link has expired. Please sign in again.", }); } return redirectWithError(`${href("/")}?error=${error}`, { message: "Invalid token", description: error, }); } } return redirectWithError( href("/"), "Something went wrong verifying your email. Please try again." ); } catch (_error) { throw new Error("Invalid token", { cause: _error }); } } export default function VerifyEmail({ loaderData }: Route.ComponentProps) { const navigation = useNavigation(); const isSubmitting = navigation.state !== "idle"; return ( <div className="flex flex-col justify-center items-center h-screen gap-10"> <Card className="max-w-md mx-auto min-w-md "> <> <CardHeader> <CardTitle className="text-lg md:text-xl"> Confirm Your Login </CardTitle> <CardDescription className="text-xs md:text-sm text-pretty"> Please click the button below to complete your login. </CardDescription> </CardHeader> <CardContent> <div className="grid gap-4"> <Form method="post"> <input id="callbackURL" name="callbackURL" defaultValue={loaderData?.callbackURL ?? undefined} hidden /> <input id="token" name="token" defaultValue={loaderData?.token} hidden /> <Button className="gap-2" type="submit" disabled={isSubmitting}> {isSubmitting ? "Verifying..." : "Complete Login"} </Button> </Form> </div> </CardContent> </> </Card> </div> ); } ```
Author
Owner

@dosubot[bot] commented on GitHub (Aug 25, 2025):

Hi, @Immortalin. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You requested changing magic link authentication from GET to POST to avoid accidental triggering by email inbox crawlers.
  • A workaround involving a verification page calling magic link verification on load was shared but noted as potentially insufficient against advanced scrapers.
  • A more robust solution using a server-side GET loader and a POST form submission requiring user interaction was implemented.
  • This solution effectively prevents accidental triggering and received positive community feedback.
  • The issue has been resolved with this approach.

Next Steps:

  • Please confirm if this solution remains relevant with the latest version of better-auth.
  • If it is still relevant, you can keep the discussion open by commenting; otherwise, I will automatically close this issue in 7 days.

Thank you for your understanding and contribution!

<!-- gh-comment-id:3220861452 --> @dosubot[bot] commented on GitHub (Aug 25, 2025): Hi, @Immortalin. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You requested changing magic link authentication from GET to POST to avoid accidental triggering by email inbox crawlers. - A workaround involving a verification page calling magic link verification on load was shared but noted as potentially insufficient against advanced scrapers. - A more robust solution using a server-side GET loader and a POST form submission requiring user interaction was implemented. - This solution effectively prevents accidental triggering and received positive community feedback. - The issue has been resolved with this approach. **Next Steps:** - Please confirm if this solution remains relevant with the latest version of better-auth. - If it is still relevant, you can keep the discussion open by commenting; otherwise, I will automatically close this issue in 7 days. Thank you for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#17713