The JSON contract
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:
'api' => [
'enabled' => true,
],
The secure default for the browser flow remains the GET confirmation page — see
Why a magic link costs one extra click.
The contract
| Outcome | Status | Body |
|---|---|---|
| Link / code requested | 200 | { "message": "…", "channel": "link"|"code" } |
| Signed in | 200 | { "authenticated": true, "two_factor": false, "redirect": "<url>" } |
| Two-factor required | 200 | { "authenticated": false, "two_factor": true, "redirect": "<challenge url>" } |
| Invalid or expired | 422 | { "message": "…", "error": "invalid_or_expired" } |
| CAPTCHA challenge failed | 422 | { "message": "…", "error": "captcha_failed" } |
| Validation failed | 422 | { "message": "…", "errors": { … } } |
| Rate limited | 429 | { "message": "…" } + Retry-After / X-RateLimit-* headers |
| Resend held back | 429 | { "message": "…", "error": "resend_throttled" } + Retry-After |
| Signature missing or expired | 403 | { "message": "Invalid signature." } — no error key |
The error code is stable and safe to branch on, while the human message stays generic so
it never reveals whether an account exists.
Posting the consume step
Since 0.25.0 the POST that spends a magic link requires the signature the emailed link
carries. A client that posts a bare token gets 403, and that response is the framework's,
not this package's — so it carries a message and no error key. Branch on the status
first, or a client that reads error unconditionally lands in an undefined case.
Use the signed URL. IssuedLink::$url is it if you mint links yourself; otherwise it is the
URL from the email, unchanged. Both routes share a URI, so that one signature verifies the
POST as well as the GET that leads to it.
$link = $issuer->issueLink($user);
// $link->url already carries `expires` and `signature`. POST to it as-is.
Http::asJson()->post($link->url)->json();
See the signed POST for why the step is bound to the host the link was built for.
Two-factor is never skipped
A two_factor response means the client must send the user to redirect to finish the TOTP
challenge. The token is already consumed at that point and the user is not logged in —
the login completes inside Fortify only after the code passes. See
The two-factor handoff.
Changing the invalid-link code
invalid_or_expired is the default value of invalid_response.error_code. Change it there if
your clients expect a different string; it is the same code the 'json' invalid-link strategy
returns to browser clients. See
Invalid or expired links.
Backing off correctly
Throttled responses carry the standard Retry-After and X-RateLimit-* headers. The resend
guard's 429 additionally carries the seconds remaining in Retry-After, so a client can
render a countdown rather than retry blindly — see The resend guard.