Recovery and operations

Verify email ownership

Confirm that a user controls an email address with a short-lived single-use token without granting more authority than verification requires.

Signup accepted [email protected] because it looked like an email address. That proves nothing about who controls the inbox. Verification proves it: we send a link only that inbox can see, and the account becomes verified when the link is used.

The token

Generate a strong random token, 32 bytes from crypto.randomBytes is fine. Store only its hash, with its purpose and expiry. Send the raw token in one HTTPS link. Consume it once.

Rate-limit the “resend” button, and keep responses generic so nobody can use the endpoint to check which addresses have accounts.

One token, one job

A verification token is a narrow capability. It proves control of one address. It must not also reset a password, log the user in, or approve an email change. Store enough state to enforce that:

token_hash | user_id | target_email | version | purpose | expires_at | consumed_at

Hashing the token means a leak of this table gives an attacker nothing usable. The raw value exists in two places only: the email, and the browser request that redeems it.

Redemption is one transaction

When the link is clicked, look up the hash and check the target address, version, purpose, and expiry. Then mark the token consumed and the address verified in a single database transaction.

Why? Two simultaneous clicks on the same link must not both succeed. With a SELECT followed by a separate UPDATE, both requests pass the check before either writes. Do it as one UPDATE ... WHERE consumed_at IS NULL and check the affected-row count. One request gets a row, the other gets zero.

Changing the email is a different problem

Confirming a new address proves control of the new inbox. It doesn’t prove the request came from the account owner. A thief with a stolen session could point the account at their own email and “recover” it.

So an email change needs three extra rules. Require a recent authenticated session before starting it. Notify the old address. And bump the version column whenever the pending address changes, so a link for an earlier address stops working.

Decide the resend policy explicitly too. Either older links stay valid until they expire, or a new token atomically invalidates the previous ones. Both work. Undefined behavior doesn’t.

A token in a URL ends up in access logs, analytics tools, the Referer header, support screenshots. Keep third-party scripts off the redemption page, never log query strings that contain tokens, and replace the URL with history.replaceState after the server consumed it.

Try this: implement the request and consume endpoints on the Books API. Then test an expired token, a replayed token, two simultaneous redemptions, resend behavior, and changing the pending email before the first link is clicked. Finish with the assertion that matters most: after a successful redemption, only the intended address is verified, and nothing else changed.

Lesson completed