Skip to main content

Extension points

Take over the post-verification flow

Rebind the authenticator contract:

use EmailMagicLink\Contracts\MagicLinkAuthenticator;

$this->app->bind(MagicLinkAuthenticator::class, MyAuthenticator::class);

The contract returns a response, so it — not an event — is where login-versus-two-factor is decided.

React to events

Events are observability only — they must not drive flow control:

  • MagicLinkRequested($user, $channel, $request) — a link or code was issued for a known user.
  • MagicLinkVerified($user, $request) — a token was verified and consumed, before the authenticator runs.
  • MagicLinkAuthenticated($user, $guard, $request) — the user was actually logged in (fires only on a completed login, never for a two-factor handoff), the precise signal for an audit log.
  • MagicLinkConsumptionFailed($reason, $request) — a consume attempt failed; $reason is a ClaimFailure, so you can log every failure and alert specifically on LockedOut (a brute-force lockout) or repeated InvalidCode.
  • TwoFactorChallengeRequired($user, $request) (fired by the bridge) — a confirmed-two-factor user is being handed to the challenge.

Each carries the Request, so a listener can record the IP address and user agent. The response stays generic and enumeration-resistant regardless of which failure reason fired. Successful logins also fire Laravel's own Illuminate\Auth\Events\Login.

The full payloads and the ClaimFailure cases are in the event reference.

Swap collaborators

Nine config keys each take a class of yours:

KeyContractWhat you control
notification— (extend MagicLinkNotification)Branding, channels, and copy of the delivered mail
user_lookupUserLookupHow a submitted email resolves to a user
eligibilitySignInEligibilityWhether an account may be handed a session at all
token_storeTokenStorePersistence, hashing, and the atomic claim
captchaCaptchaGuardA pre-issue challenge
invalid_response.viaInvalidLinkResponderThe response to an invalid or expired link
ui.script_nonceScriptNonceThe CSP nonce on every tag the bundled screens emit
invitations.storeInvitationStorePersistence, hashing, supersession and the atomic claim of invitations
invitations.handlerInvitationHandlerWhat accepting an invitation means — required when invitations are on

notification is the one entry that is not a contract: it takes a class extending MagicLinkNotification, and a class that does not is ignored without raising.

What each contract guarantees is in the contract reference.

Gate requests with a CAPTCHA

Point the captcha config at a class implementing EmailMagicLink\Contracts\CaptchaGuard:

final class TurnstileGuard implements CaptchaGuard
{
public function passes(Request $request): bool
{
// Verify the challenge token (e.g. cf-turnstile-response) with the provider.
return Http::asForm()->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => config('services.turnstile.secret'),
'response' => $request->input('cf-turnstile-response'),
])->json('success') === true;
}
}

It runs before any user lookup, so a failed challenge rejects the request identically whether or not the email exists — it can never become an enumeration oracle. A failure returns the captcha_failed JSON error (or a form error) and issues nothing.

Because it runs first, it is also the mitigation for the availability trade-off of the per-account send cap — see the hourly cap and availability.

Refuse a suspended account a session

Point the eligibility config at a class implementing EmailMagicLink\Contracts\SignInEligibility:

final class NotSuspended implements SignInEligibility
{
public function allows(Authenticatable $user, string $guard): bool
{
return $user->suspended_at === null;
}
}

It is asked twice, and the second time is why this exists as a contract rather than as middleware. A middleware that logs a suspended account out runs on the request after the one that signed it in; by then a session was opened. Gating only the issuing endpoint is no better, because a link handed out a minute before the suspension is still a valid credential afterwards and nothing looks the address up when it comes back. So the package asks on issue and again on redemption.

On issue, a refused account resolves to nothing and the endpoint answers for it exactly as it answers for an address nobody registered — same status, same redirect, same bytes. That is deliberate: a distinguishable refusal would let somebody send one link and learn both that the account exists and that it is suspended. On redemption the token is consumed and the sign-in refused, and MagicLinkConsumptionFailed carries ClaimFailure::Ineligible.

Listen for that event rather than logging inside allows(). The issuing path calls the method for accounts an attacker merely guessed at, so a log line there records somebody else's typing; the event fires only where a genuine token was presented.

Decide from the account, never from the request — there is no request on the redemption path, and a decision that needed one could not answer there.

Invitation acceptance is outside this gate. It runs through your own InvitationHandler, which is the thing deciding what an account becomes.

invalid_response.via accepts the class-string of your own EmailMagicLink\Contracts\InvalidLinkResponder when none of the four built-in strategies fits. See Invalid or expired links.

Customize the screens

Publish the Blade views and edit them:

php artisan vendor:publish --tag=email-magic-link-views

See The WireKit screens for how the WireKit variants are selected and styled.