Skip to content

auth: decide on magic-link allowedAttempts — link prefetchers block sign-in entirely #15

Description

@Ripwords

Follow-up from #13, which documented allowedAttempts: 1 but deliberately did not change it. The author flagged this as needing its own decision, and I agree — it's a real security trade-off, not a drive-by.

The problem

better-auth's magic-link verify increments the attempt counter on every GET of the link, including the successful one (better-auth/dist/plugins/magic-link/index.mjs:129):

if (attempt >= opts.allowedAttempts) {
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(storedToken);
  redirectWithError("ATTEMPTS_EXCEEDED");
}

With the default allowedAttempts: 1, the link is strictly single-use. Any mail gateway that prefetches links — Outlook Safe Links, Proofpoint, and most AV scanners — burns the attempt before the human ever clicks.

The result: a user on Outlook clicks their link for the first time and gets ATTEMPTS_EXCEEDED. Thanks to #13 they now at least see "That sign-in link has already been used. Some email providers open links automatically…" instead of a blank login form, but they still cannot sign in. For an affected mail provider this is a hard block, not a papercut.

Options

1. Leave it at 1. Status quo. Correct security posture, but sign-in is broken for anyone behind a link-prefetching gateway. Only viable if we know no user is.

2. Raise to 2 or 3. One line. Fixes prefetch. Cost: the link becomes replayable from the inbox for its full 5-minute window — anyone with read access to the mailbox (or a logged proxy, or a shared inbox) can reuse it. Mitigate by dropping expiresIn from 300s to something tighter at the same time.

3. POST-confirmation interstitial. The emailed link lands on a page with a "Sign me in" button that POSTs to the verify endpoint. Prefetchers issue GETs, never POSTs, so the attempt is only spent on a real human click — this is the standard industry fix and it keeps allowedAttempts: 1 honest. Cost: an extra click for every user, and better-auth verifies on GET, so it needs a custom route wrapping the plugin rather than a config change. Needs a feasibility spike before committing to it.

Recommendation

Option 3 is the correct fix and option 2 is the cheap one. I'd suggest deciding based on real data first: do we have any user reports of ATTEMPTS_EXCEEDED, and what mail providers are our users on? #13 made that error visible and distinguishable in the logs for the first time, so we should be able to answer that now rather than guessing.

If the answer turns out to be "yes, and they're on Outlook", go to option 2 immediately as a stopgap (with a shorter expiresIn) and spike option 3.

Where

apps/dashboard/server/lib/auth.ts — the magicLink({ … }) plugin config, which #13 annotated with the full failure-mode explanation.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions