A synthesis of secure token generation, constant-time comparison, rate limiting, and PHP's type system applied to the one feature that, when broken, can hand over an account.
Every PHP course teaches these concepts in isolation: "use random_bytes() for tokens," "use hash_equals() for comparisons," "rate limit your endpoints," "watch out for type juggling with ==." They're presented as separate lessons, separate quiz questions, separate checklist items.
Then a developer sits down to build "forgot password," combines all four incorrectly, and ships an account takeover vulnerability that passes code review because each individual line looks fine.
Password reset is the perfect case study because it's small enough to fit in one article and high-stakes enough that every shortcut has a name and a CVE history. Almost every step in the flow has a security failure mode. Let's break it seven different ways before we get it right — then look at how much of this Laravel already solves for you, and where it doesn't.
The flow, in theory
- User submits their email.
- Server generates a reset token, stores it (hashed) with an expiration, emails a link containing the token.
- User clicks the link.
- Server validates the token: does it exist, does it match, has it expired, has it been used.
- User sets a new password.
- Server invalidates the token and (ideally) every existing session.
Bug #1: The predictable token
// DON'T DO THIS
$token = md5(time() . $user['email']);
This looks like "generating something unique," and it compiles, and it works in the demo. It is also completely guessable. time() has second-level resolution — an attacker targeting a known email address can brute-force the exact timestamp window in seconds, especially if they can trigger the reset themselves and observe roughly when it was generated. md5() of a short, mostly-known input space is not cryptographic protection; it's obfuscation with extra steps.
The same failure shows up with uniqid(), mt_rand(), or anything seeded from predictable state. uniqid() is explicitly documented as not suitable for security purposes — it encodes the current time in microseconds, and even uniqid(..., true) (the "more entropy" flag) is guessable enough that it should never appear in an auth flow.
Fix:
$token = bin2hex(random_bytes(32)); // 64 hex characters, CSPRNG
random_bytes() pulls from the OS's cryptographically secure random source. 32 bytes gives you 256 bits of entropy — nobody is brute-forcing that within the token's lifetime, full stop. This is the same primitive you'd use for session IDs or CSRF tokens, because it's solving the same problem: produce a value an attacker cannot predict or reproduce.
Bug #2: Storing the token in plaintext
Say you got step one right — the token is unguessable. Now:
$stmt = $pdo->prepare(
"INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, ?)"
);
$stmt->execute([$email, $token, $expiresAt]);
The token sits in your password_resets table exactly as it was emailed. If that table leaks — SQL injection elsewhere in the app, a misconfigured backup, a careless SELECT * logged somewhere — every unexpired reset token is immediately usable by whoever has the dump. There's no reason for the server to ever need the raw token again after emailing it; it only needs to verify that what the user presents matches what it issued.
Fix: hash the token before storing it, the same way you'd never store a plaintext password, and validate the shape of whatever comes back from the client before you touch it:
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$stmt = $pdo->prepare(
"INSERT INTO password_resets (email, token_hash, expires_at, used) VALUES (?, ?, ?, 0)"
);
$stmt->execute([$email, $tokenHash, $expiresAt]);
// Only the raw $token goes in the emailed URL - never stored anywhere.
mail_reset_link($email, $token);
// On the incoming request - before it ever reaches a query or a hash function
$submittedToken = $_GET['token'] ?? null;
if (!is_string($submittedToken) || !preg_match('/^[a-f0-9]{64}$/', $submittedToken)) {
respond_invalid_or_expired();
}
That validation step matters for its own reasons — it stops a stray array (token[]=x) or an unexpected type from ever reaching your comparison or database logic, closing off a whole category of "well, that shouldn't have been possible" bugs before they start.
Notice the storage choice is deliberately not password_hash() / bcrypt. Bcrypt is designed to be slow, to resist offline brute-forcing of low-entropy human passwords. A reset token already has 256 bits of entropy — a fast hash like SHA-256 is appropriate and correct here; the security comes from the token's randomness, not from hashing cost. Using bcrypt on a token you're about to look up by exact match is a sign the two concepts (password hashing vs. token fingerprinting) got merged incorrectly.
Bug #3: Comparing tokens with == instead of hash_equals()
Here's the part where "use hash_equals()" from the course actually matters — though it's worth being precise about why, because it's easy to overstate this one.
// DON'T DO THIS
$row = $stmt->fetch();
if ($row['token_hash'] == hash('sha256', $submittedToken)) {
// proceed with reset
}
Type juggling. PHP's == performs type coercion before comparing. In this particular line, both sides are normally well-formed hex strings, so == doesn't hand an attacker an automatic bypass the way it famously did with naive md5() password checks using PHP's "magic hash" quirk (two different hashes that both look like scientific notation for zero comparing as equal under ==). But that's a fact about this specific data shape, not a reason to rely on it — the input validation from Bug #2 is what actually forecloses a type-confusion path (an array or unexpected type slipping through), and == is still the wrong operator on principle for comparing secret-derived values. Use === or hash_equals(), never ==.
Timing. Switching to strict equality raises a subtler question:
if ($row['token_hash'] === hash('sha256', $submittedToken)) { ... }
PHP's native string comparison short-circuits on the first mismatched byte, so === can leak, via response time, how many leading characters of the two hashes matched. It's worth being precise about what that actually exposes: the attacker would be learning about the SHA-256 digest, not recovering your 256-bit random token — directly reversing a hash from partial timing leakage is still computationally infeasible. So this isn't "the primary way an attacker steals your reset token" the way a guessable token generator (Bug #1) is. It's a defense-in-depth concern — a side channel that shouldn't exist, on principle, even though exploiting it here would be extraordinarily impractical.
Fix, regardless:
if (hash_equals($row['token_hash'], hash('sha256', $submittedToken))) {
// proceed
}
hash_equals() always compares the full length of both strings regardless of where they first differ, so no timing information leaks at all. The right way to think about it: hash_equals() is the correct, cost-free primitive for comparing anything secret-derived — use it by default rather than reasoning case-by-case about whether a particular leak is currently exploitable.
Bug #4: No expiration, or expiration checked wrong
A token that's cryptographically strong and safely compared is still dangerous if it works forever — and the lookup pattern matters too. Rather than fetching a reset record by email and then comparing the submitted token against it, look the record up by the token's hash directly:
$submittedToken = $_GET['token'] ?? null;
if (!is_string($submittedToken) || !preg_match('/^[a-f0-9]{64}$/', $submittedToken)) {
respond_invalid_or_expired();
}
$tokenHash = hash('sha256', $submittedToken);
$stmt = $pdo->prepare(
"SELECT id, email, token_hash, expires_at, used
FROM password_resets
WHERE token_hash = ?
LIMIT 1"
);
$stmt->execute([$tokenHash]);
$row = $stmt->fetch();
if (
!$row
|| $row['used']
|| strtotime($row['expires_at']) <= time()
|| !hash_equals($row['token_hash'], $tokenHash)
) {
// Generic failure - see Bug #7 for why the wording matters
respond_invalid_or_expired();
}
The flow becomes token → hash → find record → validate state, instead of email → find record → compare token. It's a small restructuring, but it means a reset link is self-contained: whoever holds the token can look up exactly one record, rather than the query being organized around an email address that isn't even part of what the user presented.
Short expiration windows (15–30 minutes is typical) matter independently of this — reset links get forwarded in email chains, sit in inboxes for months, get pre-fetched by corporate email security scanners that visit every link in an inbox (a real, common cause of tokens getting "used" by a bot before the actual user clicks). An unexpired token from six months ago is an open door regardless of how well everything else is built.
Bug #5: Token reuse — forgetting the token is a one-time credential
Even with expiration, a token is often valid for its entire window and checked only for "does it match and hasn't it expired" — never "has it already been consumed." That means:
- A token forwarded to a colleague, or intercepted once, works repeatedly until it expires.
- If the reset email is exposed in a proxy log, browser history on a shared machine, or a referrer header leak, anyone with the link can reset the password again later, even after the legitimate user already reset it once.
Fix: mark it used atomically, in the same statement that establishes you're allowed to proceed — this is the part worth internalizing beyond just this feature:
$pdo->beginTransaction();
$stmt = $pdo->prepare(
"UPDATE password_resets SET used = 1
WHERE token_hash = ? AND used = 0 AND expires_at > NOW()"
);
$stmt->execute([$tokenHash]);
if ($stmt->rowCount() === 0) {
$pdo->rollBack();
respond_invalid_or_expired();
}
$stmt = $pdo->prepare("UPDATE users SET password_hash = ? WHERE email = ?");
$stmt->execute([password_hash($newPassword, PASSWORD_DEFAULT), $row['email']]);
$pdo->commit();
// Invalidate existing sessions here too - a reset should log out
// every other active session, in case the account was already compromised.
The WHERE used = 0 AND expires_at > NOW() inside the same UPDATE closes a race condition that a separate SELECT then UPDATE cannot: two simultaneous requests carrying the same token can't both succeed, because only one UPDATE will actually affect a row. Checking a credential and consuming it need to be one atomic state transition, not two sequential steps — that principle applies well beyond password resets, to anything single-use: invite codes, coupon redemptions, idempotency keys.
Bug #6: Host header injection in the reset link itself
This one doesn't live in your token logic at all — it lives in how you build the URL you email.
// DON'T DO THIS
$resetLink = "https://" . $_SERVER['HTTP_HOST'] . "/reset?token=" . $token;
mail($email, "Reset your password", "Click here: $resetLink");
$_SERVER['HTTP_HOST'] (and X-Forwarded-Host) is a request header the client controls. Whether this is exploitable depends on your specific deployment — namely, whether the application or the proxy/load balancer in front of it accepts and forwards an attacker-controlled Host value instead of stripping or validating it. Where that's true, an attacker can submit a password reset request with a forged header:
POST /forgot-password HTTP/1.1
Host: attacker-controlled.com
...
email=victim@example.com
Your server dutifully generates a valid, correctly-hashed, properly-expiring, single-use token and emails the victim a link pointing to https://attacker-controlled.com/reset?token=.... The victim, trusting an email that genuinely came from your mail server, clicks it. The attacker's server logs the token and immediately replays it against your real reset endpoint. Every other bug we just fixed becomes irrelevant, because the entire token was handed to the attacker by the victim's own click.
This is a real, repeatedly-disclosed class of vulnerability, not a theoretical one — it's shown up in production frameworks and CMSs specifically through "forgot password" emails, precisely because that flow is one of the few places an app builds an absolute URL from request-influenced data and sends it somewhere sensitive.
Fix: never trust the Host header for anything security-relevant. Use a hardcoded or config-driven application URL:
// config.php
define('APP_BASE_URL', 'https://app.example.com'); // never derived from the request
$resetLink = APP_BASE_URL . '/reset?token=' . urlencode($token);
If you must support multiple environments, validate HTTP_HOST against an explicit allowlist at the point it enters your app — but for anything going into an email or a redirect, a config value is simpler and safer than validation logic you have to get right every time.
Bug #7: Enumeration and abuse — two separate problems, one endpoint
Even a perfect implementation of everything above leaks information if the endpoint responds differently depending on what happened internally:
if (!$userExists) {
return "No account found with that email."; // enumeration
}
if ($tokenInvalid) {
return "Token invalid.";
}
if ($tokenExpired) {
return "Token expired.";
}
It helps to treat these as two distinct problems, because they have two distinct fixes.
Account enumeration is about what the response reveals. Returning "No account found" vs. a success message tells an attacker which emails are registered — useful for building a credential-stuffing target list against a different, much larger breach.
// Same response whether or not the email exists
respond_generic("If that email is registered, a reset link has been sent.");
Abuse is about how often the endpoint can be hit at all — flooding a victim's inbox with reset emails, or, if some earlier bug survived, brute-forcing tokens live against the endpoint rather than offline.
if (attempts_exceeded($email, $window = 3600, $max = 3)
|| attempts_exceeded($ip, $window = 3600, $max = 10)) {
// still return the generic message - don't let the rate limiter
// itself become a new oracle by responding differently once triggered
respond_generic("If that email is registered, a reset link has been sent.");
return;
}
Generic responses close the enumeration leak; rate limiting closes the abuse vector. They solve different problems and aren't substitutes for each other — a beautifully generic response message still doesn't stop someone from hammering the endpoint 50,000 times a minute, and a strict rate limit doesn't stop an attacker from telling registered emails apart on their very first request.
Rate limit by email and by IP, independently — email-only limiting lets an attacker distribute requests across many IPs, IP-only limiting lets them rotate target emails from one machine.
What Laravel already does for you
If you're building this on Laravel rather than raw PHP, the built-in Password broker (Illuminate\Auth\Passwords\PasswordBroker) already handles several of these correctly out of the box:
use Illuminate\Support\Facades\Password;
$status = Password::sendResetLink(
$request->only('email')
);
-
Token generation uses a cryptographically secure random string, and Laravel stores only its hash in the
password_reset_tokenstable — Bugs #1 and #2 are handled for you. -
Comparison goes through
hash_equals()internally when validating the submitted token against the stored hash — Bug #3 is handled. -
Expiration is configurable via
auth.php:
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60, // minutes
'throttle' => 60, // seconds between requests
],
],
The throttle value gives you request-level rate limiting on the sending side for free — Bug #7's abuse half is largely covered.
-
Enumeration is handled if you use the generic status messages Laravel already returns (
PASSWORD_RESET_LINK_SENTetc.) rather than branching your own response onPassword::INVALID_USER.
What Laravel does not automatically save you from:
-
Bug #5 (reuse): Laravel deletes the token row after a successful reset, so a single successful use does invalidate it — but if you've customized the reset controller and swapped in your own completion logic, it's easy to lose that deletion. Confirm
Password::reset()(or your override) actually removes the row, and consider also revoking existing sessions/tokens for the user as part of that same flow, since Laravel doesn't do that for you by default. -
Bug #6 (host header injection): this is the one Laravel developers miss most often, because
url()androute()helpers build absolute URLs from the current request's host by default. If your app sits behind a proxy that isn't listed inTrustProxies, or ifAPP_URLisn't set and Laravel falls back to inferring the host, a forgedHost/X-Forwarded-Hostheader can still end up baked into the reset email. SetAPP_URLexplicitly in.env, configureTrustProxies(or Laravel'strustProxies()inbootstrap/app.phpon newer versions) to only trust your actual load balancer, and consider usingURL::forceRootUrl()for anything as sensitive as a password reset link. - IP-based throttling on the reset submission endpoint (as opposed to the send-link endpoint) still needs Laravel's throttle middleware applied explicitly to that route — the framework's built-in throttle config governs how often a link can be requested, not how many reset attempts can be made against a given token.
The takeaway isn't "Laravel is safe, raw PHP is dangerous" — it's that a framework encoding these decisions for you is exactly the kind of defense-in-depth that raises the floor. It also means the two places worth double-checking in any Laravel app are the ones the framework can't infer on your behalf: what happens to the token row after a successful reset, and where the host in that email link actually comes from.
Putting it together
None of these seven bugs is exotic. Individually, each is a small, well-known fix. What makes password reset a genuinely dangerous feature is that it sits at the intersection of all of them simultaneously — cryptographic randomness, safe comparison, database hygiene, time handling, transactional state, trust boundaries around the Host header, and abuse prevention — all in a flow that's maybe 80 lines of code (or a few config values, in Laravel).
That's also exactly why it's worth building once, correctly, as a reference rather than reimplementing from memory in every project, or trusting a framework default you've never actually read. If you've internalized random_bytes(), hash_equals(), rate limiting, and the type-juggling pitfalls of == as separate facts, this flow is where you find out whether you understand why each one exists, not just that it exists.
Code review and correct primitives are the first layer. They should catch all seven of these before anything ships. A second, independent layer — logging and flagging unusual traffic patterns against the reset endpoint itself, like repeated token attempts from one source or a burst of requests across many emails from one IP — doesn't fix a broken implementation, but it does mean that if something upstream slips through anyway, you find out from a log line instead of a breach report. Tools built for exactly that job (Kriosa is one example, for PHP and Laravel apps specifically) sit at the application boundary and watch for that kind of probing without touching the reset logic itself — worth having, but never a substitute for getting the seven bugs above right first.
If you're auditing an existing reset flow, the fastest way to find problems is usually: read the token generation line, read the comparison line, then check whether the Host header ever touches the emailed URL. Those three spots account for the large majority of real-world reset vulnerabilities.
What Is Kriosa?
Kriosa is an application-level security layer for PHP and Laravel applications. It sits at the application boundary and analyzes incoming requests for suspicious traffic before that traffic reaches sensitive application logic.
For password reset flows, Kriosa can provide an additional detection and visibility layer by helping surface suspicious request patterns associated with attempts to probe, brute-force, or abuse a "forgot password" endpoint.
But Kriosa is not a replacement for building the reset flow correctly.
If your application exposes a password reset endpoint, the primary fix is to get the flow itself right: cryptographically random tokens, constant-time comparison, single-use enforcement, generic responses, and a reset link built from a trusted URL rather than the request's Host header.
Think of Kriosa as defense in depth, not a substitute for a correctly implemented reset flow.
How Kriosa Can Help Detect Suspicious Reset-Flow Activity
Password reset abuse is fundamentally an application-layer problem.
The dangerous condition is not simply that someone submitted a password reset request. Legitimate users forget passwords constantly, and a single reset request is completely normal.
The real problem is when a pattern of requests suggests someone is probing the flow itself — testing whether emails exist, guessing at tokens, replaying old links, or hammering the endpoint from many sources at once.
That makes detection useful as an additional layer, but it does not change where the vulnerability must ultimately be fixed: in the reset flow's own logic.
Patterns worth investigating include:
- Many reset requests for different emails from one IP (enumeration sweep)
- Repeated token attempts against the reset-submission endpoint (token brute-forcing)
- A token being presented after it should already be expired or used (replay)
- Reset requests whose Host header doesn't match the app's known domain (host header injection attempts)
For example, a burst of POST /reset-password requests carrying malformed or near-miss tokens against a single account may be worth investigating even though no individual request looks obviously malicious.
The important word is pattern.
A single reset request, or a single wrong token, is not automatically an attack. Context and volume matter.
Kriosa can add another layer of visibility by helping developers identify suspicious request patterns, unusual timing between requests, and repeated probing of the forgot-password and reset-submission endpoints.
The distinction is important:
A detection signal is not proof of an attack, and detection is not the same as prevention.
Prevention Comes First
The application should still:
- Generate tokens with
random_bytes(), nevermd5(time()),uniqid(), ormt_rand(). - Store only a hash of the token, never the raw value.
- Compare tokens with
hash_equals(), never==or bare===. - Enforce a short expiration window and mark tokens used atomically, in the same query that checks them.
- Return the same generic response regardless of whether the email exists, whether the token was wrong, or whether it expired.
- Build the reset link from a hardcoded or config-driven application URL, never from
$_SERVER['HTTP_HOST']. - Rate limit by email and by IP independently, on both the send-link and reset-submission endpoints.
These controls address the vulnerability itself.
Detection Adds Another Layer
A correctly built reset flow closes off the known failure modes, but it does not guarantee that every future change to the flow preserves that correctness.
An attacker sweeping an endpoint with many emails, or repeatedly submitting near-miss tokens against one account, may be attempting to find a gap in the implementation — or attempting to brute-force it live.
That activity is useful security telemetry.
Kriosa is designed to provide additional application-level visibility into suspicious traffic — helping developers detect, log, investigate, and respond to activity that may indicate an attack on the reset flow.
In Laravel applications, the built-in PasswordBroker already handles token generation, hashing, and comparison correctly out of the box. But two things it doesn't automatically catch are a forged Host/X-Forwarded-Host header ending up in the emailed link, and abnormal request volume against the reset-submission route rather than the send-link route. Both are exactly the kind of traffic pattern a detection layer is positioned to flag.
The goal is not to make a broken reset flow safe.
The goal is to get the flow right first, then add visibility around the traffic attempting to reach it.
Why Kriosa?
Security controls can fail.
A developer can copy a token-generation snippet from an old tutorial that used md5(time()). A custom override of the reset controller can quietly drop the "delete token after use" step. A proxy change can stop stripping a forged Host header before it reaches the app. A rate limit can get applied to the wrong route.
That is why defense in depth matters.
A practical security model can look like:
Secure flow → Input validation → Detection → Logging & response
Kriosa fits into the detection layer.
Your reset flow should prevent the vulnerability.
Your validation should reject malformed tokens and unexpected input.
Kriosa can provide additional visibility into suspicious application traffic.
Your logs and monitoring can help you investigate and respond.
Kriosa does not replace a secure reset flow. It adds another layer for detecting and monitoring the traffic that reaches it.
Password Reset Flow Security Checklist
Before shipping a "forgot password" feature in PHP or Laravel, ask:
- [ ] Do I generate tokens with
random_bytes(), notmd5(time()),uniqid(), ormt_rand()? - [ ] Do I store only a hash of the token, never the raw value?
- [ ] Do I compare tokens with
hash_equals(), never==? - [ ] Do I validate the shape of the submitted token before it reaches a query or hash function?
- [ ] Is the token's expiration checked, and short (15–30 minutes)?
- [ ] Is the token marked used atomically, in the same statement that validates it?
- [ ] Does a successful reset invalidate the token and existing sessions?
- [ ] Does the endpoint return the same generic response whether the email exists, the token is wrong, or it's expired?
- [ ] Is the reset link built from a config-driven
APP_URL, never from the request'sHostheader? - [ ] If behind a proxy, is
TrustProxies(or equivalent) configured to trust only the real load balancer? - [ ] Am I rate limiting by email and by IP independently, on both the send-link and reset-submission endpoints?
- [ ] Are suspicious requests against the reset endpoints being logged and monitored?
- [ ] Do I have an additional detection layer for probing or brute-force patterns on the reset flow?
The goal isn't simply to use the right primitives once.
The goal is to make sure an attacker never gets the chance to guess, replay, or hijack a reset link that should only ever reach one legitimate user.
Try Kriosa
If you want an additional application-level security layer for your PHP or Laravel application:
Try Kriosa: kriosa.com
Install it with:
composer require kriosa-ai/kriosa-php
Documentation: kriosa.com/documentation.php
Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.
Top comments (0)