Route reference
All routes are registered whenever the channel is enabled; the configured mode governs
which one actually issues a token, not which routes exist. They all run under
routes.middleware (default ['web']) and behind routes.prefix (default empty).
| Method | URI | Name | Extra middleware | Changes state |
|---|---|---|---|---|
GET | /magic-link | email-magic-link.request.form | — | No |
POST | /magic-link | email-magic-link.request | throttle: limiters.request | Yes — issues a token and queues the mail |
GET | /magic-link/verify/{token} | email-magic-link.confirm | signature checked in the controller | No — inert confirmation page |
POST | /magic-link/verify/{token} | email-magic-link.consume | throttle: limiters.consume, signature checked in the controller | Yes — consumes the token |
GET | /magic-link/code | email-magic-link.code.form | — | No |
GET | /magic-link/resend-countdown.js | email-magic-link.resend-countdown-script | — | No — serves the resend countdown's client script |
POST | /magic-link/code | email-magic-link.code.consume | throttle: limiters.consume | Yes — consumes the code |
GET | /magic-link/invitation/{token} | email-magic-link.invitation.show | throttle: limiters.invitation_view | No — inert acceptance page |
POST | /magic-link/invitation/{token} | email-magic-link.invitation.accept | throttle: limiters.consume, signature checked in the controller | Yes — spends the invitation |
The inert GET
email-magic-link.confirm is the route the emailed link points at. It is signed and it
performs no authentication and no state change — it only renders the confirmation page. The
single-use token is spent exclusively by the POST to email-magic-link.consume.
Both routes verify the signature in the controller rather than with the signed middleware.
The signature expires with the token, and the middleware answered an expired link, the most
common refusal there is, with a bare 403. Now it gets the configured refusal every other
dead token gets, and the MagicLinkConsumptionFailed event reports it as Expired.
That split is what makes the flow safe against link-following email security scanners and browser prefetch; the reasoning is in Why a magic link costs one extra click and in the security model.
The signed POST
The POST that spends the token is signed as well. Both routes share the URI, so the
signature the emailed link already carries verifies the POST unchanged, and the
confirmation page's form just posts back to the URL it was reached at. That URL is what the
$action variable holds. The invitation acceptance form works the same way.
This binds redeeming a token to the host the link was built for. Without it the signature on
the GET protected nothing that mattered: the token is the whole credential, so anyone who
read it out of a link could post it bare to another host of the application and be signed in.
It does not stop a forged host; see
the host in the emailed link.
If you publish the views, render $action as you were given it. Rebuilding it with
route('email-magic-link.consume', ['token' => $token]) drops the signature, and the request
gets the generic refusal. The same goes for anything that posts the flow programmatically. Use the
signed URL from the email, or IssuedLink::$url if you mint links yourself, rather than
assembling one from the bare token.
Referring to the routes
Point your "log in" link at the request form:
route('email-magic-link.request.form')
The names are stable public API; the URIs move with routes.prefix, so build URLs from the
names rather than hardcoding paths.
Moving or wrapping the flow
'routes' => [
'prefix' => 'auth',
'middleware' => ['web', 'my-middleware'],
],
The web group is required — the flow needs sessions and CSRF. Add to it rather than
replacing it.
The invitation routes, and why the GET is not signed
The two invitation routes register only when invitations.enabled is true. They mirror the
inert-GET split above for the same reason, and the URL is signed the same way — but the
signed middleware is deliberately not on the route.
That middleware answers an expired signature with a 403 and an unknown token with the
generic page, and those two answers are distinguishable. An invitation lives for seven days
by default, so expiry is the ordinary case rather than the edge. The signature is verified
inside the controller instead and a failure folded into the same refusal every other dead
token gets.
See invitations.