PHPackages                             pushery/email-magic-link-for-laravel - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. pushery/email-magic-link-for-laravel

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

pushery/email-magic-link-for-laravel
====================================

Passwordless email magic-link &amp; OTP authentication for Laravel — standalone or with a correct, no-bypass Fortify 2FA handoff and scanner-safe link consumption.

v0.1.1(today)20MITPHPPHP ^8.4

Since Jun 22Pushed todayCompare

[ Source](https://github.com/pushery/email-magic-link-for-laravel)[ Packagist](https://packagist.org/packages/pushery/email-magic-link-for-laravel)[ Docs](https://github.com/pushery/email-magic-link-for-laravel)[ RSS](/packages/pushery-email-magic-link-for-laravel/feed)WikiDiscussions main Synced today

READMEChangelog (10)Dependencies (9)Versions (2)Used By (0)

Email Magic Link for Laravel
============================

[](#email-magic-link-for-laravel)

[![Latest Version on Packagist](https://camo.githubusercontent.com/4afc49ff2be540b6c6541506005964a50ea9f66981ce57b6de277df5cd9c00b7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f707573686572792f656d61696c2d6d616769632d6c696e6b2d666f722d6c61726176656c2e737667)](https://packagist.org/packages/pushery/email-magic-link-for-laravel)[![PHP Version](https://camo.githubusercontent.com/ca3d347760248c33fa5209d6142184d2d6dd1a86d6355224087811318a50b81e/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f707573686572792f656d61696c2d6d616769632d6c696e6b2d666f722d6c61726176656c2e737667)](https://packagist.org/packages/pushery/email-magic-link-for-laravel)[![PHPStan](https://camo.githubusercontent.com/745eb989b9e4903dc598fe2cc63ed4226198be55b7c729001cbd1ece7676fef6/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6d61782d627269676874677265656e2e737667)](https://phpstan.org/)[![Code Style](https://camo.githubusercontent.com/d1e49fbc2c712416be5a417fd3a8d339062c657ecbe46e4ada4dd0156dfd325f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d70696e742d6f72616e67652e737667)](https://laravel.com/docs/pint)[![License](https://camo.githubusercontent.com/dc293adf78eee4a6053f3a8b2cd800dc998d03c9826ef389f330f3b8424b3296/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f707573686572792f656d61696c2d6d616769632d6c696e6b2d666f722d6c61726176656c2e737667)](https://packagist.org/packages/pushery/email-magic-link-for-laravel)

Passwordless email authentication for Laravel — magic links and one-time codes — that works **standalone** or alongside **Laravel Fortify**.

Plenty of packages send a magic link. This one is built around two properties most of them get wrong:

### 1. A correct, no-bypass Fortify two-factor handoff

[](#1-a-correct-no-bypass-fortify-two-factor-handoff)

If a user has confirmed TOTP through Fortify, clicking a magic link does **not** log them in. Instead they are handed off to Fortify's own two-factor challenge in a not-yet-authenticated state, and the login only completes inside Fortify after the code is verified. There is no path that signs a two-factor user in without the second factor — and an end-to-end test runs the real Fortify challenge to keep it that way across Fortify upgrades.

### 2. Scanner-safe and prefetch-safe link consumption

[](#2-scanner-safe-and-prefetch-safe-link-consumption)

The emailed link is a `GET` that **only renders a confirmation page** — it performs no authentication and no state change. The single-use token is consumed solely by an explicit `POST` from that page. Corporate email security scanners (Microsoft SafeLinks, Mimecast, Proofpoint) and browser prefetch follow the `GET` and cannot burn the link before the human clicks "Sign in".

---

Requirements
------------

[](#requirements)

ComponentConstraintPHP`^8.4` (8.4 and 8.5)Laravel`^13.0`Laravel Fortify`^1.0` — optional, only for the two-factor handoffThe package requires `laravel/framework` (for the `FormRequest` base it validates with) and adds no third-party runtime dependencies. Fortify is a **suggested** dependency; the core never references a Fortify symbol unless Fortify is installed and the bridge is enabled.

Installation
------------

[](#installation)

```
composer require pushery/email-magic-link-for-laravel
```

Then run the installer to publish the configuration and print the next steps:

```
php artisan email-magic-link:install
```

Add `--views` to also publish the Blade views. The migration is loaded automatically, so a fresh app works without publishing anything.

Prefer to do it by hand? The individual publish tags are still available:

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

Quick start
-----------

[](#quick-start)

Out of the box the package registers a complete browser flow under the `web` middleware group:

MethodURINamePurpose`GET``/magic-link``email-magic-link.request.form`"Enter your email" form`POST``/magic-link``email-magic-link.request`Issue a link or code`GET``/magic-link/verify/{token}``email-magic-link.confirm`Inert, signed confirmation page`POST``/magic-link/verify/{token}``email-magic-link.consume`Consume a magic link`GET``/magic-link/code``email-magic-link.code.form`Enter a one-time code`POST``/magic-link/code``email-magic-link.code.consume`Consume a one-time codePoint your "log in" link at `route('email-magic-link.request.form')` and you have passwordless login. A user enters their email, receives a link, clicks it, confirms, and is signed in.

The three configurations
------------------------

[](#the-three-configurations)

**Standalone — no Fortify.** A verified user is logged in directly with `Auth::login`. There is no second factor in standalone mode, by design.

**With Fortify, bridge on (`fortify.mode = 'auto'`, the default).** A user with confirmed TOTP is routed through Fortify's challenge; everyone else logs in directly.

**With Fortify, bridge off (`fortify.mode = false`).** Fortify can be installed for other flows while the magic-link channel ignores it entirely and logs users in directly.

The channel itself can be turned off completely with `enabled = false`, independent of whether Fortify is installed.

Why a magic link costs one extra click
--------------------------------------

[](#why-a-magic-link-costs-one-extra-click)

Because consumption is `POST`-only, the user clicks the emailed link (a `GET`) and then clicks "Sign in" on the confirmation page. That second click is the price of being safe against link-following security scanners and prefetch — tools that would otherwise spend a single-use token before the person ever sees it. We consider that trade-off worth it; it is the whole point of the package.

For first-party SPA or mobile clients that exchange the token over JSON without an interstitial, set `api.enabled = true` and send `Accept: application/json`. The endpoints then speak a stable JSON contract:

OutcomeStatusBodyLink / code requested`200``{ "message": "…", "channel": "link"|"code" }`Signed in`200``{ "authenticated": true, "two_factor": false, "redirect": "" }`Two-factor required`200``{ "authenticated": false, "two_factor": true, "redirect": "" }`Invalid or expired`422``{ "message": "…", "error": "invalid_or_expired" }`Validation failed`422``{ "message": "…", "errors": { … } }`Rate limited`429``{ "message": "…" }` + `Retry-After` / `X-RateLimit-*` headersThe `error` code is stable and safe to branch on, while the human `message` stays generic so it never reveals whether an account exists. A `two_factor` response means the client must send the user to `redirect` to finish the TOTP challenge — the second factor is never skipped.

The two-factor handoff (and its trade-off)
------------------------------------------

[](#the-two-factor-handoff-and-its-trade-off)

When the bridge is active and a verified user has **confirmed** two-factor authentication (gated on `two_factor_confirmed_at`, not merely a stored secret, so a user mid-setup is never locked out):

1. The token is consumed.
2. Fortify's `login.id` session key is set and the request is redirected to Fortify's `two-factor.login` challenge — **without** logging the user in.
3. The login completes inside Fortify only after the TOTP code passes.

**Trade-off:** the token is already spent when the handoff happens, so if a user abandons the TOTP step they must request a fresh link. This is intentional — the link is single-use and the challenge is a separate, deliberate step.

**Guard alignment:** when the handoff is enabled, `email-magic-link.guard` must resolve to the same provider as `fortify.guard`, because Fortify re-resolves the challenged user from its own guard's provider. With mismatched providers the challenge fails closed (the user cannot complete login) rather than logging anyone in. The default `web` guard satisfies this out of the box.

`fortify.respect_two_factor = false` disables this handoff. **This is a security downgrade: magic-link logins will skip two-factor for users who have it enabled.** It emits a warning at boot.

Configuration
-------------

[](#configuration)

All values live in `config/email-magic-link.php`.

KeyDefaultPurpose`enabled``true`Master switch for the channel (routes, notifications, limiters).`mode``'link'``'link'`, `'code'`, or `'both'`.`ttl``900`Default token lifetime in seconds.`link_ttl``null`Link lifetime in seconds; inherits `ttl` when unset.`code_ttl``null`Code lifetime in seconds; inherits `ttl` when unset (handy for a shorter, hand-typed code).`code_length``8`One-time code length.`code_alphabet`unambiguous A–Z/2–9Alphabet for codes (governs keyspace).`max_attempts_per_token``5`Hard per-token lockout for code mode.`entropy_safety_factor``1_000_000`Guardrail bar; cannot be lowered below this floor.`guard`app defaultDefault stateful guard to log into.`guards``[]`Extra guards a request may select via a `guard` field.`user_lookup`bundled`UserLookup` implementation.`token_store`bundled`TokenStore` implementation.`notification``MagicLinkNotification`Notification class (extend it to customize).`routes.prefix``''`Route prefix.`routes.middleware``['web']`Route middleware (sessions + CSRF).`routes.redirect_to``'/'`Fallback redirect after login.`routes.intended``true`Return to the originally requested URL after login.`api.enabled``false`Direct JSON token exchange for SPA/mobile.`ui.mode``'auto'``'auto'` (WireKit views if installed) or `'blade'`.`ui.vite``['resources/css/app.css']`Vite entry the WireKit layout loads.`fortify.mode``'auto'``'auto'` (on if Fortify present), `true`, or `false`.`fortify.respect_two_factor``true`Route confirmed-2FA users through the challenge.`fortify.challenge_route``'two-factor.login'`Fortify challenge route name.`limiters.request` / `limiters.consume`named limitersOverride with `RateLimiter::for()`.`limits.request` / `limits.consume``5` / `10` per minuteDefaults for the bundled limiters.One-time codes
--------------

[](#one-time-codes)

Set `mode` to `'code'` (or `'both'`) to email a short code instead of a link. Codes are governed by a **boot-time entropy guardrail**: the package refuses to boot if a code's keyspace divided by its attempt lockout falls below `entropy_safety_factor`, naming the exact keys to fix and the minimum length that would pass. Magic links carry 256 bits of entropy and pass trivially.

In `'both'` mode the request endpoint issues a link by default, or a code when `channel=code` is submitted.

Cleaning up tokens
------------------

[](#cleaning-up-tokens)

Every request inserts a row, and consumption only marks it consumed. Schedule the bundled command to delete expired and consumed tokens so the table stays small:

```
use Illuminate\Support\Facades\Schedule;

Schedule::command('email-magic-link:purge')->daily();
```

Translations
------------

[](#translations)

Every user-facing string — the views, the notification, and the status and error responses (the "we sent a link", "invalid or expired", and challenge-failed messages) — runs through Laravel's translator under the `email-magic-link`namespace, so everything follows the application's active locale. English, German, Spanish, French, Italian, Dutch, and Portuguese ship in the box. Publish the language files to translate, reword, or add more:

```
php artisan vendor:publish --tag=email-magic-link-lang
```

That copies the strings to `lang/vendor/email-magic-link/{locale}`. Add a locale by copying the `en` directory (for example to `de`) and translating the values; the `:app` and `:minutes` placeholders are filled in at render time.

Multiple guards
---------------

[](#multiple-guards)

By default everything runs through the configured `guard`. To let a request sign in to another guard — say an `admin` guard alongside `web` — list it in `guards`and submit a `guard` field from your sign-in form:

```
'guard' => 'web',
'guards' => ['admin'],
```

```

```

The request issues the token for the selected guard, the user is resolved through **that guard's** user provider, and login completes on it. A guard not on the allowlist falls back silently to the default, so guards stay un-enumerable.

> Security: only list guards whose user provider you are happy to expose to self-service magic-link login. A user found in a guard's provider can sign in to that guard, so guards that share a provider also share access. When the Fortify two-factor handoff is active, the selected guard should match `fortify.guard`.

WireKit
-------

[](#wirekit)

If [WireKit](https://wirekit.app) (`pushery/wirekit`) is installed, the sign-in screens render with WireKit components automatically — no configuration needed. Without it, the package serves its own dependency-free Blade views, so it works either way. Set `ui.mode` to `blade` to keep the plain views even when WireKit is present.

WireKit emits Tailwind classes and Alpine directives, so its views render inside a layout that loads your compiled CSS via `@vite` (configurable with `ui.vite`, default `resources/css/app.css`) together with `@livewireScripts` and `@wirekitScripts`. The flow itself is unchanged — the same signed routes, CSRF-protected POSTs, and single-use token consumption — only the look differs.

Extension points
----------------

[](#extension-points)

**Take over the post-verification flow** by rebinding 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-2FA is decided.

**React to events** (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 hand-off), the precise signal for an audit log.
- `MagicLinkConsumptionFailed($reason, $request)` — a consume attempt failed; `$reason` is a `ClaimFailure` (`NotFound`, `Expired`, `InvalidCode`, `LockedOut`, `AlreadyConsumed`), 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-2FA user is being handed to the challenge.

Each carries the `Request`, so a listener can record the IP 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`.

**Swap collaborators** via config: the `notification` class (extend `MagicLinkNotification`), a `UserLookup` (resolve users your way), a `TokenStore` (custom persistence), and a `CaptchaGuard` (a pre-issue challenge).

**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.

Security at rest
----------------

[](#security-at-rest)

Tokens are never stored in the clear — only a keyed HMAC-SHA256 hash, looked up via an index. Consumption is a single race-free conditional claim (PostgreSQL `RETURNING`, with a portable affected-rows fallback) so two concurrent requests can never both succeed. Links are additionally protected by Laravel signed routes. Raw tokens and full link URLs are never logged.

The request endpoint is rate-limited per email and per IP out of the box. For high-risk deployments, layer a CAPTCHA or challenge widget on top via the `captcha` guard (see [Extension points](#extension-points)) as an additional bot-protection measure. Throttled responses carry the standard `Retry-After` and `X-RateLimit-*` headers, so API and SPA clients can back off correctly.

See [SECURITY.md](SECURITY.md) for the supported versions and how to report a vulnerability.

Versioning
----------

[](#versioning)

This package follows [Semantic Versioning](https://semver.org). It is in its `0.x` line while the public API settles; the backward-compatibility promise begins at `1.0.0`.

License
-------

[](#license)

The MIT License. See [LICENSE](LICENSE).

###  Health Score

39

—

LowBetter than 85% of packages

Maintenance100

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity40

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/b684549435f88b531446bf3d993ab1ec3c5a4f3a8b6409a6f60f9836a964fc0f?d=identicon)[pushery](/maintainers/pushery)

---

Top Contributors

[![pushery](https://avatars.githubusercontent.com/u/272318911?v=4)](https://github.com/pushery "pushery (15 commits)")

---

Tags

emaillaravellaravel-frameworklaravel-packagemailpasswordlesspasswordless-authenticationpasswordless-loginlaravelotpAuthentication2faloginPasswordlessmagic-linkfortify

###  Code Quality

TestsPest

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/pushery-email-magic-link-for-laravel/health.svg)

```
[![Health](https://phpackages.com/badges/pushery-email-magic-link-for-laravel/health.svg)](https://phpackages.com/packages/pushery-email-magic-link-for-laravel)
```

###  Alternatives

[lakm/nopass

Provides passwordless authentication for your laravel projects.

2215.1k3](/packages/lakm-nopass)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
