A password reset token is a temporary credential that will hand over any account it names. We treat it with the same suspicion as a session cookie, and it usually deserves more, because it is often generated by code written once and never revisited.
The mistake is almost never “the token was too short”. It is that the token looked long while carrying almost no randomness.
Length is not entropy
Entropy is how many values the token could have been, not how many characters it has. A 40-character hex string looks like 160 bits. If 32 of those characters are a millisecond timestamp and a process counter, an attacker who knows roughly when the reset was requested is guessing across a few million values, not 2^160.
The cliff is steep and it is in a place people do not expect. Below about 40 bits you are inside the window of a patient attacker with one machine. Above 64 you are outside the window of everybody, forever. There is no reason to live near the edge when the fix costs one line.
The three sources that break
- A timestamp, sometimes hashed. If we can request a reset for our own account at a known moment, we can bracket yours to a narrow window and enumerate it.
- A sequential or user-derived identifier, dressed up with an encoding that looks random and is not.
- A non-cryptographic random source: Math.random(), rand(), or a language default seeded predictably at process start. Some of these leak their internal state after a handful of outputs, at which point every future token is computable rather than guessable.
The method is not clever. We register a handful of accounts we control, request resets in a tight loop, and lay the tokens out next to each other. If the middle eight characters increment, or the first twelve are identical across every token issued in the same second, the work is already done.
1716823401-a4f19c2e0b
1716823401-a4f19c2e0c
1716823402-a4f19c2e11
1716823404-a4f19c2e1aTwenty characters, and the first ten are a Unix timestamp we control by choosing when to click the button. The remaining suffix moves by small increments. Nominal length, twenty characters. Real entropy, under 24 bits, the highlighted bar above.
What we do next, and what we do not
We prove the takeover against an account we control, capture the request and response, and stop. We do not touch real user accounts to demonstrate impact, and the report says exactly where we stopped and why the next step would have worked.
The fix
- Generate at least 128 bits from a cryptographically secure source. Your platform has one. Use it rather than the convenient one. OWASP ASVS sets 64 bits as the floor for session tokens; a credential that hands over an account deserves more, and 128 costs nothing extra.
- Store a hash of the token, not the token, so a database read does not hand over live resets.
- Expire in fifteen to sixty minutes, and invalidate on first use, on password change and on a second reset request.
- Rate-limit and alert on reset-token submission. Guessing needs volume, and volume is the one thing you can see.
- Do not leak account existence in the response. The same message and the same timing whether or not the address is registered.
# python
token = secrets.token_urlsafe(32) # 256 bits
// node
const token = crypto.randomBytes(32).toString("base64url");
// store hash(token), never token