Build1 publisher2 min readPublished
An md5 reset token shrinks the attacker's search space to 3,600 guesses an hour
A dev.to walkthrough breaks PHP and Laravel password reset on purpose to show where it fails, and the first three failures are all lines that pass code review on their own. Two of the fixes are one line each.
The Engineer · Build desk

What happened
- A dev.to walkthrough of PHP and Laravel password reset argues that courses teach random_bytes(), hash_equals(), rate limiting and == type juggling as four separate lessons with separate checklist items.
- Its worked anti-pattern is $token = md5(time() . $user['email']), which the piece calls guessable because time() resolves to the second and the target email address is already known.
- The replacement is bin2hex(random_bytes(32)): 64 hex characters carrying 256 bits of entropy, drawn from the operating system's cryptographically secure random source.
- For storage, a sha256 hash of the token goes into a token_hash column alongside an expiry and a used flag, and the raw token appears only in the emailed URL.
- Incoming tokens are rejected unless they are strings matching /^[a-f0-9]{64}$/, checked before the value reaches a hash function or a database query.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure With raw tokens in the table, an unrelated SQL injection or a stale backup gives the holder working reset links for every pending request; with the column hashed, the same dump holds nothing that can be presented at the endpoint.
- decision Line-level review will pass this feature, so a team that wants to catch it has to review generation, storage, comparison and input typing together in one pass over the flow.
- cost The fixes cost one sha256 call at issue and one at verification, plus a regex at the boundary; choosing bcrypt instead would add a deliberate delay to every verification of a value that has no dictionary behind it.
- constraint Rejecting anything that is not 64 hex characters leaves the endpoint with exactly one accepted input shape, so array and wrong-type parameters never get as far as the database layer.
Price the guessable token. `time()` moves in one-second steps, and an attacker who knows the target address can start the reset himself, so he knows roughly when the row was written [5]. One hour of uncertainty is 3,600 candidate timestamps [15]. Each candidate costs one md5 of a string he already holds [4]. The article calls md5 over a short, mostly-known input space "obfuscation with extra steps" [6]. `uniqid()` lands in the same place: it encodes the clock in microseconds and is documented as unsuitable for security purposes, even with the extra-entropy flag [7].
The regex on the way back in is the same 256 bits stated as a filter. `/^[a-f0-9]{64}$/` accepts 16^64 strings, and 16^64 is exactly 2^256, so the check rejects nothing the generator can emit [16]. What it does reject is `token[]=x`, and anything else whose type would surprise the comparison or the query underneath it [13].
Hashed storage is the step that limits the damage from a bug somewhere else in the app. Once the mail has gone out the server never needs the raw value again; it only has to know that what arrives matches what it issued [10]. The article's list of ways the table gets out is SQL injection elsewhere in the app, a misconfigured backup, and a careless `SELECT *` that ended up in a log [9].
Storage uses sha256, and the article says the choice against `password_hash()` is deliberate: bcrypt is slow by design, to make offline guessing of low-entropy human passwords expensive [14]. The token starts at 256 bits [8].
The insecure insert in the piece is a PDO prepared statement with bound parameters, correct on its own terms; the value going into the token column is the one that was emailed [18]. That is the review problem the article is describing, and each broken version compiles [2]. The flow's last step invalidates the token and, the piece says, ideally every existing session [3]. It names `hash_equals()` and rate limiting in the same set of lessons [1] and promises seven broken versions before the working one, plus a look at how much Laravel already solves [17]. Those sections are not in the material at hand, so the checkable advice covers generation, storage and input typing.
What to watch
- Whether the article's Laravel comparison finds a framework default that leaves a reset token in plaintext anywhere.
- The rate-limiting section: whether it counts per email address, per IP, or both, and what it does on the enumeration case.
- Whether the session-invalidation step, marked "ideally" in the article's flow, gets working code in the finished version.