Skip to main content

The resend guard

Fixed-window limits cap volume but still let a "send again" button fire on every click until the cap is hit. The resend guard layers an escalating cooldown and a rolling cap on top: after each send the next one for that email is held back a little longer — 30 seconds, then 60, then 120, up to a ceiling — and no more than five go out per hour.

It is on by default and keyed per email, so it never depends on whether an account exists. Turn it off with resend.enabled = false; tune it with the resend.* keys:

note

resend.enabled governs this package's request endpoint only. If your application uses the guard for its own keys — see Reusing the guard — those stay guarded whatever this switch says. Before 0.19.0 it was a global kill-switch, so turning off magic-link throttling also disarmed your own.

'resend' => [
'enabled' => env('EMAIL_MAGIC_LINK_RESEND', true),

'cooldown' => [
'base' => 30,
'factor' => 2,
'max' => 900,
],

'window' => [
'minutes' => 60,
'max_sends' => 5,
],

'store' => null,
],

A held-back request is not a dead end

The caller is told how many seconds remain. Browsers get the count flashed to the session — the bundled request screen disables the button and counts down — and JSON clients get a 429 with a Retry-After header:

{ "message": "Please wait 30 seconds before requesting another sign-in email.", "error": "resend_throttled" }

The resend_throttled code is stable and safe to branch on, while the human message stays generic. It is translated in every bundled locale — see Translations.

Use it for your own endpoints

The same guard is a public service you can wrap around your own mail-sending endpoints — a "resend code" button on a custom challenge, a re-invite, a password-reset resend. Inject the EmailMagicLink\Contracts\ResendGuard contract and gate the send with a key of your choosing:

use EmailMagicLink\Contracts\ResendGuard;

public function __construct(private ResendGuard $guard) {}

public function resend(Request $request): Response
{
$decision = $this->guard->attempt('challenge:'.$request->user()->id);

if (! $decision->allowed) {
// Seconds until the next send is allowed — render a countdown.
return back()->with('retry_after', $decision->retryAfterSeconds);
}

// …send your mail…
}

A denied decision also carries reason, a ResendDenialReason of Cooldown or WindowCap, so you can word the two cases differently. The full signatures are in the contract reference.

A key you own is always guarded. resend.enabled switches off throttling on this package's request endpoint and nothing else, so an operator disabling magic-link resends can never silently disarm the flood protection on, say, your second-factor challenge.

The rules the guard follows

  • attempt($key) records a send only when it allows one. A denied attempt changes no state, so a client polling the endpoint cannot push its own cooldown out. Use peek($key) to read the current decision without recording anything — handy for rendering a countdown before the user acts.

  • reset($key) starts the key over. Both the cooldown ladder and the rolling window clear. The package calls this for its own key once a link or code issued for the address is verified, so a real sign-in is never punished; call it yourself after your own flow completes.

  • Pick your own key namespace. Keys share the cache store, so prefix them (challenge:{id}, invite:{email}) to avoid colliding with the package's own request:{email} keys or each other. Normalize an email key yourself if you mix casings.

  • The store must support atomic locks. The guard takes a short lock per attempt so concurrent requests cannot each slip past the cap; the array, file, database, Redis, and Memcached stores all qualify. Point resend.store at one, or leave it null for the default cache store.

    On a store that is not a lock provider at all — apc — the guard refuses to run. On the null store it used to fail open, which is the opposite, and it is worth knowing why: null is a lock provider, and its lock succeeds every time. So the interface check passed, the guard evaluated its window under a lock that excluded nothing, and it threw nothing and throttled nothing — unlimited mail per address, quietly. It now rejects that lock by name. Nothing else about null was ever going to work here anyway: the counter it writes is discarded too.

  • The state is a cache entry. php artisan cache:clear (and optimize:clear, which many deploy scripts run) resets every cooldown and every rolling window at once. The array store holds the state for one PHP process only, so on it the guard takes its lock and throttles nothing — fine for a test, not for a deployment. A store that cannot hand out the lock in time holds the request back for a few seconds rather than erroring.

The hourly cap and availability

The cap is keyed on the submitted email, so an unauthenticated caller who knows an address can spend that address's hourly budget and keep the account from receiving a link for up to an hour. That is the nature of any per-account send cap — the same shape as the per-minute limiter, just over a longer window — and it is the price of guaranteeing a hard ceiling on mail to one inbox.

The guard runs after the CAPTCHA (see Extension points), so in a hostile setting, enabling the captcha guard stops an attacker from reaching the cap at all. Raise resend.window.max_sends, shorten the window, or set resend.enabled = false if the cooldown alone is enough for your threat model.