Skip to main content

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

OutcomeStatusBody
Link / code requested200{ "message": "…", "channel": "link"|"code" }
Signed in200{ "authenticated": true, "two_factor": false, "redirect": "<url>" }
Two-factor required200{ "authenticated": false, "two_factor": true, "redirect": "<challenge url>" }
Invalid or expired422{ "message": "…", "error": "invalid_or_expired" }
CAPTCHA challenge failed422{ "message": "…", "error": "captcha_failed" }
Validation failed422{ "message": "…", "errors": { … } }
Rate limited429{ "message": "…" } + Retry-After / X-RateLimit-* headers
Resend held back429{ "message": "…", "error": "resend_throttled" } + Retry-After
Signature missing or expired403{ "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.

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.