[GH-ISSUE #5550] Magic Link - Option to allow multiple attempts #27605

Closed
opened 2026-04-17 18:41:54 -05:00 by GiteaMirror · 12 comments
Owner

Originally created by @Phillip9587 on GitHub (Oct 24, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/5550

Is this suited for github?

  • Yes, this is suited for github

One-time magic links are frequently consumed by email security scanners (Microsoft Defender Safe Links, Gmail's link checking, etc.) before users can click them. This results in "link expired" errors and poor user experience.

Describe the solution you'd like

Add an allowAttempts option to allow multiple uses of a magic link within its validity period. Default to 1 to maintain current behavior.

import { createAuthClient } from "better-auth/client";
import { magicLinkClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
    plugins: [
        magicLinkClient({
            allowedAttempts: 3
        })
    ]
});

Describe alternatives you've considered

  • Requiring additional UI confirmation step (reduces "magic" convenience)
  • IP-based validation
  • User-agent filtering (unreliable and easily bypassed)

Additional context

Originally created by @Phillip9587 on GitHub (Oct 24, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/5550 ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. One-time magic links are frequently consumed by email security scanners (Microsoft Defender Safe Links, Gmail's link checking, etc.) before users can click them. This results in "link expired" errors and poor user experience. ### Describe the solution you'd like Add an `allowAttempts` option to allow multiple uses of a magic link within its validity period. Default to `1` to maintain current behavior. ```ts import { createAuthClient } from "better-auth/client"; import { magicLinkClient } from "better-auth/client/plugins"; export const authClient = createAuthClient({ plugins: [ magicLinkClient({ allowedAttempts: 3 }) ] }); ``` ### Describe alternatives you've considered - Requiring additional UI confirmation step (reduces "magic" convenience) - IP-based validation - User-agent filtering (unreliable and easily bypassed) ### Additional context - #2174 - https://github.com/nextauthjs/next-auth/issues/1840 - https://github.com/FusionAuth/fusionauth-issues/issues/629
GiteaMirror added the lockedenhancement labels 2026-04-17 18:41:54 -05:00
Author
Owner

@emileindik commented on GitHub (Nov 5, 2025):

I'm running into this issue as well. We need to be able to configure the number of attempts, or to just disable attempt-based invalidation, and only rely on the expiration-time-based invalidation.

<!-- gh-comment-id:3493483582 --> @emileindik commented on GitHub (Nov 5, 2025): I'm running into this issue as well. We need to be able to configure the number of attempts, or to just disable attempt-based invalidation, and only rely on the expiration-time-based invalidation.
Author
Owner

@Phillip9587 commented on GitHub (Nov 5, 2025):

@emileindik My PR #5552 would allow this by setting allowedAttempts to Infinity. This could possibly be improved further but it at least fixes the problem described.

<!-- gh-comment-id:3493556609 --> @Phillip9587 commented on GitHub (Nov 5, 2025): @emileindik My PR #5552 would allow this by setting `allowedAttempts` to `Infinity`. This could possibly be improved further but it at least fixes the problem described.
Author
Owner

@dimylkas commented on GitHub (Dec 10, 2025):

Hi @ping-maxwell , can you review the changes?

<!-- gh-comment-id:3639398450 --> @dimylkas commented on GitHub (Dec 10, 2025): Hi @ping-maxwell , can you review the changes?
Author
Owner

@zaru commented on GitHub (Dec 24, 2025):

This is critical for corporate use. Security scanners invalidate links immediately, and since they spoof UserAgents, we can't filter them.

I'm currently using an intermediate page as a workaround, but the UX is poor. I'd strongly prefer a solution that allows direct login.

<!-- gh-comment-id:3690110973 --> @zaru commented on GitHub (Dec 24, 2025): This is critical for corporate use. Security scanners invalidate links immediately, and since they spoof UserAgents, we can't filter them. I'm currently using an intermediate page as a workaround, but the UX is poor. I'd strongly prefer a solution that allows direct login.
Author
Owner

@DrewJohnsonGT commented on GitHub (Jan 6, 2026):

Running into the same problem and seeing similar issues from corporate security scanners

<!-- gh-comment-id:3714912836 --> @DrewJohnsonGT commented on GitHub (Jan 6, 2026): Running into the same problem and seeing similar issues from corporate security scanners
Author
Owner

@DystopianDisco commented on GitHub (Jan 8, 2026):

ok this is a big one - multiple enterprise clients can no longer access via magic link.

Did something change recently with Microsoft safe links? This is now a critical issue - and magic links are no longer viable.

<!-- gh-comment-id:3725618793 --> @DystopianDisco commented on GitHub (Jan 8, 2026): ok this is a big one - multiple enterprise clients can no longer access via magic link. Did something change recently with Microsoft safe links? This is now a critical issue - and magic links are no longer viable.
Author
Owner

@jleguina commented on GitHub (Jan 15, 2026):

+1

<!-- gh-comment-id:3757407448 --> @jleguina commented on GitHub (Jan 15, 2026): +1
Author
Owner

@DrewJohnsonGT commented on GitHub (Jan 16, 2026):

Not everyone has the option but we implemented OTP in the meantime for enterprise clients with these types of security scanners

<!-- gh-comment-id:3761950801 --> @DrewJohnsonGT commented on GitHub (Jan 16, 2026): Not everyone has the option but we implemented [OTP](https://www.better-auth.com/docs/plugins/email-otp) in the meantime for enterprise clients with these types of security scanners
Author
Owner

@pcohen12 commented on GitHub (Jan 21, 2026):

We're also seeing the same issue with enterprise clients. As a simple "solution", we're using this in our auth config's databaseHooks for now to allow verifications to be used multiple times until they expire (after the default 5 min):

verification: {
  delete: {
    before: async (verification) => {
      return Date.now() > verification.expiresAt.getTime();
    },
  },
},
<!-- gh-comment-id:3780749461 --> @pcohen12 commented on GitHub (Jan 21, 2026): We're also seeing the same issue with enterprise clients. As a simple "solution", we're using this in our auth config's `databaseHooks` for now to allow verifications to be used multiple times until they expire (after the default 5 min): ```js verification: { delete: { before: async (verification) => { return Date.now() > verification.expiresAt.getTime(); }, }, }, ```
Author
Owner

@garytube commented on GitHub (Feb 3, 2026):

it took me days to figure out what was happening. glad to see i'm not the only one :D
please merge the allowedAttempts option PR - it would help us alot

<!-- gh-comment-id:3843936668 --> @garytube commented on GitHub (Feb 3, 2026): it took me days to figure out what was happening. glad to see i'm not the only one :D please merge the allowedAttempts option PR - it would help us alot
Author
Owner

@jtiser commented on GitHub (Feb 5, 2026):

I had to implement email OTP and remove our email magic link implementation because of the safe link scanner and the prefetch from some email clients.

Having such PR merged would be wonderful.

<!-- gh-comment-id:3855029099 --> @jtiser commented on GitHub (Feb 5, 2026): I had to implement email OTP and remove our email magic link implementation because of the safe link scanner and the prefetch from some email clients. Having such PR merged would be wonderful.
Author
Owner

@garytube commented on GitHub (Feb 5, 2026):

We're also seeing the same issue with enterprise clients. As a simple "solution", we're using this in our auth config's databaseHooks for now to allow verifications to be used multiple times until they expire (after the default 5 min):

verification: {
  delete: {
    before: async (verification) => {
      return Date.now() > verification.expiresAt.getTime();
    },
  },
},

I slightly modified this to have also a max attempts

// In-memory store to cap magic link reuse at MAX_VERIFICATION_ATTEMPTS.
// Workaround for https://github.com/better-auth/better-auth/issues/5550
// Corporate email scanners (Mimecast, SentinelOne) pre-click links and consume tokens.
// Remove once better-auth adds native `allowedAttempts` support.
const MAX_VERIFICATION_ATTEMPTS = 3;
const verificationAttempts = new Map<string, number>();

export const auth = betterAuth({
  // ...
  databaseHooks: {
    verification: {
      delete: {
        before: async (verification) => {
          const expired = Date.now() > verification.expiresAt.getTime();
          if (expired) {
            verificationAttempts.delete(verification.identifier);
            return true; // allow deletion
          }

          const attempts = (verificationAttempts.get(verification.identifier) ?? 0) + 1;
          verificationAttempts.set(verification.identifier, attempts);

          if (attempts >= MAX_VERIFICATION_ATTEMPTS) {
            verificationAttempts.delete(verification.identifier);
            return true; // allow deletion (max attempts reached)
          }

          return false; // prevent deletion, token stays valid
        }
      }
    }
  }
});
<!-- gh-comment-id:3855525839 --> @garytube commented on GitHub (Feb 5, 2026): > We're also seeing the same issue with enterprise clients. As a simple "solution", we're using this in our auth config's `databaseHooks` for now to allow verifications to be used multiple times until they expire (after the default 5 min): > ```js > verification: { > delete: { > before: async (verification) => { > return Date.now() > verification.expiresAt.getTime(); > }, > }, > }, > ``` I slightly modified this to have also a max attempts ```ts // In-memory store to cap magic link reuse at MAX_VERIFICATION_ATTEMPTS. // Workaround for https://github.com/better-auth/better-auth/issues/5550 // Corporate email scanners (Mimecast, SentinelOne) pre-click links and consume tokens. // Remove once better-auth adds native `allowedAttempts` support. const MAX_VERIFICATION_ATTEMPTS = 3; const verificationAttempts = new Map<string, number>(); export const auth = betterAuth({ // ... databaseHooks: { verification: { delete: { before: async (verification) => { const expired = Date.now() > verification.expiresAt.getTime(); if (expired) { verificationAttempts.delete(verification.identifier); return true; // allow deletion } const attempts = (verificationAttempts.get(verification.identifier) ?? 0) + 1; verificationAttempts.set(verification.identifier, attempts); if (attempts >= MAX_VERIFICATION_ATTEMPTS) { verificationAttempts.delete(verification.identifier); return true; // allow deletion (max attempts reached) } return false; // prevent deletion, token stays valid } } } } }); ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#27605