Skip to main content

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

MethodURINameExtra middlewareChanges state
GET/magic-linkemail-magic-link.request.formNo
POST/magic-linkemail-magic-link.requestthrottle: limiters.requestYes — issues a token and queues the mail
GET/magic-link/verify/{token}email-magic-link.confirmsignedNo — inert confirmation page
POST/magic-link/verify/{token}email-magic-link.consumethrottle: limiters.consume, signedYes — consumes the token
GET/magic-link/codeemail-magic-link.code.formNo
GET/magic-link/resend-countdown.jsemail-magic-link.resend-countdown-scriptNo — serves the resend countdown's client script
POST/magic-link/codeemail-magic-link.code.consumethrottle: limiters.consumeYes — consumes the code
GET/magic-link/invitation/{token}email-magic-link.invitation.showthrottle: limiters.invitation_viewNo — inert acceptance page
POST/magic-link/invitation/{token}email-magic-link.invitation.acceptthrottle: limiters.consume, signature checked in the controllerYes — 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.