When a code is submitted for the wrong address, the rejection is careful about what it says:
if ($payloadEmail !== $email) {
Cache::put($attemptsKey, $attempts + 1, now()->addMinutes(15));
// Deliberately does not echo the address the code belongs to — that
// would turn a lucky guess into an account-discovery oracle.
return Response::json([
'verified' => false,
'error' => 'Email mismatch',
'reason' => 'The verification code was sent to a different email address',
'provided_email' => $email,
// …
]);
}
The helpful version — "that code belongs to j**@example.com"* — turns one lucky guess into a way of discovering who is signing up. The response echoes back only the address the caller already supplied.
Note that the mismatch still costs an attempt. A rejection that is free is not a limit.
There is a nuance worth being honest about, because this tool does distinguish two other cases:
$wasSent = Cache::has("verify-code-sent:{$code}");
Expired and invalid get different messages, which is technically information disclosure — it confirms a code once existed. It is a deliberate trade: without it, a user whose code aged out is told their correct code is wrong, and support tickets follow. The thing that makes it acceptable is that the distinction reveals nothing about whose code it was, and the attempt budget still applies.
Ask of every error message: what does this confirm to someone who is guessing? Then decide, rather than defaulting to helpful.
Bindings Made at Issue Time Are Authoritative
This is the subtlest one, and the easiest to get wrong while writing entirely reasonable code.
When the code was sent, it was bound to a lead. The tool also accepts a lead_id argument, because the model has one in context. So which wins?
// The code->lead binding set when the code was sent is authoritative. Only
// fall back to a caller-supplied lead_id when that binding is gone, and even
// then only if the lead actually belongs to the just-verified email —
// otherwise a caller could attach their verified email to someone else's
// lead and thread it into the site creation.
$leadId = Cache::pull("verify-code-lead-id:{$code}");
Trusting the argument means: verify an address you control, pass somebody else's lead id, and now your verified email is attached to their record — which then flows into creation. Every individual step passes its own validation. The composition is the hole.
A value established at issue time beats a value supplied at use time, always. And where a fallback is unavoidable, it must be re-checked against something already proven — here, that the lead actually belongs to the address just verified.
Watch for this shape in your own tools: an argument that duplicates something the server already knows. Either the server's copy wins, or you have a parameter that lets a caller re-point the operation.