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 | signed | No — inert confirmation page |
POST | /magic-link/verify/{token} | email-magic-link.consume | throttle: limiters.consume, signed | 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.
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 the real application and be signed in. 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
comes back 403. 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.