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.
Follow-up from #13, which documented
allowedAttempts: 1but 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):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
expiresInfrom 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: 1honest. 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— themagicLink({ … })plugin config, which #13 annotated with the full failure-mode explanation.