Password Reset and Email Verification — Single-Use Tokens, Hashing, and Enumeration-Resistant Responses
Build a secure password-reset flow: generate a cryptographically random single-use token, store only its hash in the database with an expiry, email the raw token to the user, verify a reset request by hashing the incoming token and comparing it in the database, and respond identically regardless of whether the submitted email exists -- and apply the same token pattern to email verification.
Password reset is one of the most attacked flows in any application, precisely because it's designed to bypass the normal login check -- it has to let someone in without their current password, based only on proving control of their email inbox. Getting it wrong is catastrophic: a guessable or reusable reset token, or a reset token stored in a form an attacker can read straight out of a database dump, hands over full account takeover with none of the protections password hashing was supposed to provide. This lesson builds the flow end-to-end, and the same token pattern -- generate, hash, store hash, email raw value, verify by re-hashing -- applies equally to email verification links.
Generating and Storing the Reset Token
A reset token must be cryptographically random and long enough to be practically unguessable -- random_bytes(32) converted to a URL-safe string (e.g. via bin2hex() or base64url encoding) gives 256 bits of entropy, far beyond what brute-forcing could ever reach within any reasonable expiry window. It must never be predictable from anything about the user (not derived from their email, user ID, or the current timestamp alone) and must be single-use.
Crucially, the RAW token is never stored in the database. Instead, hash it (a fast cryptographic hash like SHA-256 is appropriate here -- unlike password hashing, this isn't defending against offline brute-forcing of a low-entropy secret; the token itself already has 256 bits of entropy, so the hash only needs to prevent someone who reads the database from directly reading out a usable token) and store that hash, alongside the user ID and an expiry timestamp typically 15 minutes to 1 hour out. The raw token itself is sent to the user by email and never persisted anywhere server-side.
This mirrors why a database breach shouldn't hand out live reset capability the same way it shouldn't hand out live passwords: if the raw tokens were stored directly, anyone who reads that table (via a SQL injection, a backup leak, an insider) could immediately reset any user's password using the stolen tokens, even without ever seeing an email. Storing only the hash means the breach yields nothing usable -- exactly the same reasoning as never storing plaintext passwords.
• Generate with random_bytes(32) (or similar CSPRNG source) -- never anything derived from user data or predictable state. • Store only a HASH of the token (SHA-256 is fine here) plus user ID and expiry -- never the raw token. • Email the RAW token to the user; it is never written to the database or logs. • Set a short expiry (commonly 15-60 minutes) and enforce single-use by deleting/invalidating the token record once it's used.
Requesting a Reset Without Leaking Account Existence
When a user submits 'forgot password' with an email address, the response must be identical whether or not that email has a registered account -- something like 'If an account exists for that email, a reset link has been sent.' Returning a different message for a registered vs. unregistered email (or even the same message but noticeably faster for one case) is the same account-enumeration leak covered in the secure-login lesson, just on a different endpoint.
This means the token-generation and email-sending logic runs conditionally, but the HTTP response does not vary based on that condition -- the server internally checks whether the account exists, generates and stores a token and sends an email only if it does, but always returns the same generic message either way, and ideally after a similar amount of processing time so the two code paths don't diverge measurably in latency either.
• Always return the same generic confirmation message, regardless of whether the email is registered. • Only generate a token and send an email when the account actually exists -- but that branching stays entirely server-side, invisible in the response. • Keep the timing of both branches comparable, for the same reason as the login flow's dummy-hash trick. • This applies to registration ('email already in use') and email-verification resend flows too, not just password reset.
Verifying and Consuming the Token
When the user clicks the emailed link (containing the raw token as a query parameter), the server hashes the SUBMITTED token with the same algorithm used at generation time and looks up a matching, non-expired, unused row by that hash -- never by looking up the raw token directly, since only hashes are stored. If no matching, unexpired row is found, the reset is rejected with a generic message ('This reset link is invalid or has expired') that doesn't distinguish 'never existed', 'expired', or 'already used' -- each of those distinctions is also a minor information leak an attacker doesn't need.
On a valid match, the token record must be deleted or marked used IMMEDIATELY as part of setting the new password (ideally in the same database transaction), so the same link cannot be replayed to reset the password again later. After the password is updated, it's good practice to also invalidate ALL of that user's existing sessions -- forcing re-login everywhere -- since a password reset is exactly the moment an account may have been compromised, and any session an attacker might already hold should be revoked along with the change.
The comparison of the incoming (re-hashed) token against the stored hash should use a constant-time comparison the same way signature verification does, though in practice looking the row up by an indexed hash column in the database (an equality WHERE clause) is the normal implementation and doesn't introduce the same byte-by-byte timing concern that comparing two strings in PHP application code would.
• Hash the incoming raw token the same way as generation, then look up by that hash -- never by the raw value. • Reject with one generic message for 'not found', 'expired', and 'already used' alike -- don't distinguish them to the client. • Delete/invalidate the token record as part of the same operation that sets the new password -- enforcing single-use. • Invalidate the user's other active sessions after a successful reset, since a reset often follows a suspected compromise.
The Same Pattern for Email Verification
Email verification links use an identical mechanism: generate a random token, store only its hash with an expiry tied to the account's pending-verification state, email the raw token as a link, and on click, hash the submitted token and look up a match to mark the account verified. The only structural difference from password reset is what a successful verification DOES (flip an email_verified_at flag rather than accept a new password), and that verification tokens are often allowed to be resent/regenerated more liberally than reset tokens, since verifying an email is lower-stakes than authorizing a password change.
Enumeration resistance still applies to registration and resend-verification endpoints for the same reason: telling an unauthenticated caller 'that email is already verified' or 'no pending signup for that email' leaks account existence just as directly as a differentiated password-reset message would.
• Same generate -> hash -> store hash -> email raw -> re-hash-and-compare pattern, applied to a verification flag instead of a password field. • Verification tokens can reasonably allow more liberal resends than reset tokens, since the stakes of a stolen verification token are lower. • Resend-verification endpoints need the same enumeration-resistant generic response as password reset.
Token Generation and Hash Lookup
hash('sha256', ...) is appropriate here -- the token already has 256 bits of entropy, unlike a low-entropy user password. The database is queried by token_hash, never by any raw token value -- the raw token never touches storage. expires_at > time() in the same WHERE clause avoids an extra round trip to check expiry separately.
// Generating (never store this raw value)
$rawToken = bin2hex(random_bytes(32)); // 64 hex chars, 256 bits of entropy
$tokenHash = hash('sha256', $rawToken);
$expiresAt = time() + 3600; // 1 hour
Database::query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)',
[$userId, $tokenHash, $expiresAt]
);
// Email $rawToken (e.g. embedded in a link) — never $tokenHash, never persisted.
mail($userEmail, 'Reset your password', "Reset link: https://example.com/reset?token={$rawToken}");
// Verifying (on the link click, given the raw token from the query string)
$submittedHash = hash('sha256', $submittedRawToken);
$row = Database::queryOne(
'SELECT user_id FROM password_resets WHERE token_hash = ? AND expires_at > ?',
[$submittedHash, time()]
);Code Examples
The database lookup and token generation happen conditionally, but the function's return value -- and therefore the HTTP response -- is exactly the same regardless of which branch ran, so a caller cannot tell from the response whether the email was registered. This single return statement runs whether or not the if ($user !== null) block executed, which is precisely what keeps the two cases indistinguishable to the client.
return ['message' => 'If an account exists for that email, a password reset link has been sent.'];
Common mistake: Returning ['error' => 'No account with that email'] when $user is null and a different success message otherwise -- directly leaking which emails are registered through this endpoint.
The incoming raw token is hashed and looked up alongside its expiry in one query. On success, the new password hash is written, the token row is deleted so the same link can never be reused, and all of that user's other sessions are wiped -- all inside one transaction so a failure partway through doesn't leave the account in an inconsistent state. Expected result: On success: password updated, reset link now dead, all prior sessions logged out
Database::query('DELETE FROM password_resets WHERE id = ?', [$reset['id']]);
Common mistake: Setting the new password but forgetting to delete the token row -- leaving the same emailed link usable to reset the password again and again until it naturally expires.
<?php
declare(strict_types=1);
function requestPasswordReset(string $email): array
{
$user = Database::queryOne('SELECT id FROM users WHERE email = ?', [$email]);
if ($user !== null) {
$rawToken = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $rawToken);
Database::query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)',
[$user['id'], $tokenHash, time() + 3600]
);
sendResetEmail($email, $rawToken); // raw token only ever leaves via email
}
// Identical response whether or not $user was found — no branching visible externally.
return ['message' => 'If an account exists for that email, a password reset link has been sent.'];
}<?php
declare(strict_types=1);
function completePasswordReset(string $submittedRawToken, string $newPassword): array
{
$tokenHash = hash('sha256', $submittedRawToken);
$reset = Database::queryOne(
'SELECT id, user_id FROM password_resets WHERE token_hash = ? AND expires_at > ?',
[$tokenHash, time()]
);
if ($reset === null) {
// Covers "never existed", "expired", and "already used" (deleted) alike — no distinction given.
return ['error' => 'This reset link is invalid or has expired'];
}
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
$newHash = password_hash($newPassword, $algo);
Database::transaction(function () use ($reset, $newHash) {
Database::query('UPDATE users SET password_hash = ? WHERE id = ?', [$newHash, $reset['user_id']]);
// Delete the token row immediately — enforces single-use, prevents replay of this link.
Database::query('DELETE FROM password_resets WHERE id = ?', [$reset['id']]);
// Invalidate every other active session for this user — a reset may follow a compromise.
Database::query('DELETE FROM sessions WHERE user_id = ?', [$re
…Reset token lifecycle: raw value only ever exists outside the database
The raw token exists in exactly two places -- the user's inbox and briefly in transit -- never in the database.
generate: random_bytes(32) -> rawToken
|
+-------------+-------------+
| |
hash('sha256') email to user
| |
v v
stored in DB: tokenHash user's inbox: rawToken
(+ user_id, expires_at) (never touches the DB)
user clicks link with rawToken
|
v
hash('sha256', rawToken) --> lookup by hash in DB
|
match + not expired?
/ \
yes no
| |
set password, generic "invalid or
delete token row, expired" message
kill other sessionsImportant Rules
Generate reset/verification tokens with a cryptographically secure random source (random_bytes) of at least 256 bits, never derived from user or timestamp data.
Store only a hash of the token in the database, with an expiry; the raw token is emailed and never persisted server-side.
Enforce single-use by deleting or marking the token record used at the exact moment it's successfully consumed, inside the same transaction as the password change.
Return an identical generic response for 'request reset' regardless of whether the email exists, and an identical generic error for 'invalid', 'expired', and 'already used' tokens.
Invalidate the user's other active sessions after a successful password reset, since a reset often follows a suspected account compromise.
Why reset links expire so quickly and only work once
Password-reset emails from major providers (Google, GitHub, banks) consistently expire within roughly 15 minutes to a few hours and explicitly say the link 'can only be used once' -- both are the single-use, short-expiry token pattern in this lesson, and both exist specifically to shrink the window in which an intercepted email (a compromised inbox, a forwarded message, a shared computer's browser history) could be abused to take over the account.
Storing the raw reset token directly in a database column (e.g. reset_token = 'a1b2c3...') and looking it up as-is when the link is clicked.
Store only hash('sha256', $rawToken), look up by that hash, and never write the raw token anywhere persistent.
If the database is ever read by anyone unauthorized (breach, leaked backup, injection), storing raw tokens hands over live, ready-to-use account-takeover capability for every pending reset -- identical in severity to storing plaintext passwords.
Responding 'No account found for that email' when the submitted email isn't registered, and a different message when it is.
Always return the same generic 'If an account exists, a reset link has been sent' message, regardless of which branch actually ran server-side.
A differentiated response turns the password-reset form into an account-enumeration oracle, letting an attacker cheaply confirm which email addresses have accounts on the site.
Why should a password-reset token's HASH be stored in the database instead of the raw token itself? A. Hashing makes the token shorter and easier to email B. So that a database breach doesn't hand an attacker live, directly usable reset tokens for every pending request C. PHP does not allow storing long random strings in a database D. It makes the token expire automatically
B. So that a database breach doesn't hand an attacker live, directly usable reset tokens for every pending request — If raw tokens were stored, anyone who reads that table through a breach could immediately use them to reset accounts without ever intercepting the email -- storing only the hash means a database breach yields nothing directly usable, the same reasoning as never storing plaintext passwords.
What response should a 'forgot password' endpoint give when the submitted email has no matching account? A. An error clearly stating no account exists for that email B. The exact same generic message it would give for a valid, registered email ('If an account exists, a reset link has been sent') C. A 404 Not Found response D. A redirect to the registration page
B. The exact same generic message it would give for a valid, registered email ('If an account exists, a reset link has been sent') — Returning a different message (or a message with different timing) for a nonexistent email leaks account existence -- an enumeration vulnerability -- so both cases must produce an identical response.
Adapt the completePasswordReset() pattern into an email-verification flow: write verifyEmailToken(string $submittedRawToken) that hashes the token, looks it up in an email_verifications table (columns: id, user_id, token_hash, expires_at), and on a valid unexpired match, sets users.email_verified_at = NOW() and deletes the verification row, returning a generic success message either way on failure.
function verifyEmailToken(string $submittedRawToken): array { $tokenHash = hash('sha256', $submittedRawToken); $row = Database::queryOne('SELECT id, user_id FROM email_verifications WHERE token_hash = ? AND expires_at > ?', [$tokenHash, time()]); if ($row === null) return ['error' => 'This verification link is invalid or has expired']; Database::transaction(function () use ($row) { Database::query('UPDATE users SET email_verified_at = ? WHERE id = ?', [time(), $row['user_id']]); Database::query('DELETE FROM email_verifications WHERE id = ?', [$row['id']]); }); return ['message' => 'Your email has been verified.']; }
Structurally identical to completePasswordReset() -- swap the UPDATE target from password_hash to email_verified_at, and the DELETE target table to email_verifications.