Designing a Secure Email Verification Flow: Hashing, Atomicity, and the Resend Problem
While building a production-style authentication system (Node.js/Express, PostgreSQL with raw SQL, Redis, BullMQ), one of the smaller-looking features ended up teaching me the most: email verification. On the surface it's just "generate a token, email it, check it later." In practice, it forced me to think carefully about hashing strategy, atomicity, and data-access direction — the kind of design thinking that doesn't show up until you actually try to ship the feature end-to-end.
Here's a walkthrough of the decisions and bugs I ran into.
Step 1: Generating and Storing the Token
The first design choice: never store the raw token anywhere, including in your database or cache. If your Redis instance is ever compromised, a table of raw verification tokens is a direct account-takeover vector. Instead, I generate a random raw token, hash it, and store only the hash:
const generateEmailVerificationToken = () => {
const rawToken = crypto.randomBytes(32).toString("hex");
const hashedToken = crypto.createHash("sha256").update(rawToken).digest("hex");
return { rawToken, hashedToken };
};
The raw token goes in the email link. The hash becomes the Redis key:
await redisClient.set(
`verify:${hashedToken}`,
newUser.id,
"EX",
EMAIL_VERIFY_TTL_SECONDS,
);
A question worth asking here: why SET with an EX flag instead of SETEX? They're functionally identical, but Redis has consolidated most of its write-with-expiry variants (SETEX, PSETEX, SETNX) under the single SET command with flags (EX, PX, NX, XX, KEEPTTL). Standardizing on SET means fewer commands to reason about across a codebase, and it's the more current idiom.
Step 2: Why SHA-256, Not bcrypt
This is a detail that's easy to get wrong if you're pattern-matching from password hashing. Passwords need bcrypt (or argon2) because they're low-entropy secrets — humans pick guessable passwords, so the hash needs to be deliberately slow and salted to resist brute force.
A verification token is different: it's 256 bits of cryptographically random data, not something a human chose. Brute-forcing it is computationally infeasible regardless of hash speed. So there's no reason to pay bcrypt's performance cost. SHA-256 is deterministic and fast — exactly the properties you want here, because verification becomes:
- Take the incoming raw token from the query string.
- Hash it the same way.
- Ask Redis if a key with that exact hash exists.
There's no bcrypt.compare()-equivalent step needed. The "comparison" is the key lookup — Redis's own hash table handles the equality check internally. Your only job is making sure the hashing method at verification time exactly matches the one used at generation time (same algorithm, same encoding, no stray whitespace from req.query.token).
Step 3: The Atomicity Bug I Almost Shipped
My first draft of the verification handler looked like this:
const userId = await redisClient.get(`verify:${hashedToken}`);
if (!userId) throw new ApiError(400, "Invalid or expired token");
await redisClient.del(`verify:${hashedToken}`);
This has a classic TOCTOU (time-of-check to time-of-use) race condition: if two requests hit this endpoint with the same token at nearly the same time (a double-click, a retried request, a malicious replay), both can pass the get check before either executes the del. The token gets "used" twice.
The fix is to make the read-and-delete a single atomic operation using Redis's GETDEL:
const userId = await redisClient.getdel(`verify:${hashedToken}`);
if (!userId) throw new ApiError(400, "Invalid or expired verification token");
This is the same category of bug I'd already hit earlier in my registration controller (a duplicate-email race resolved via Postgres's 23505 unique-violation error code). Seeing the same pattern reappear in a completely different part of the system — Redis instead of Postgres — was a good reminder that race conditions aren't tied to a specific database; they show up anywhere state is read and then acted on in two separate steps.
Step 4: The Resend Problem — Wrong-Direction Data
Once verification worked, I moved to "resend verification email" and got stuck immediately:
// I have userId here... but how do I find their existing token key?
await redisClient.del(`verify:${???}`);
This exposed a real design gap. My key structure was verify:{hashedToken} → userId, which is perfect for the direction verification needs (token → user). But resend needs the opposite direction (user → token), and I had no way to get there — the raw token was already emailed and discarded, and hashes can't be reversed by design.
Two ways to solve this:
Reverse index (the approach I went with): maintain a second Redis key that maps user → current token hash, written at the same time as the original:
await redisClient.set(`verify:${hashedToken}`, newUser.id, "EX", EMAIL_VERIFY_TTL_SECONDS);
await redisClient.set(`verify:user:${newUser.id}`, hashedToken, "EX", EMAIL_VERIFY_TTL_SECONDS);
Resend then becomes a clean two-step lookup and cleanup:
const oldHashedToken = await redisClient.get(`verify:user:${user.id}`);
if (oldHashedToken) {
await redisClient.del(`verify:${oldHashedToken}`);
await redisClient.del(`verify:user:${user.id}`);
}
// generate + store new token pair here
Do nothing (the lazier but not unsafe option): since getdel already invalidates a token on first use and TTL expires it either way, skipping cleanup isn't a security hole — just a "loose ends" problem. Old tokens sit in memory until TTL, and a user who spams resend could momentarily have multiple valid tokens. Fine for a prototype; not the invariant I wanted for a portfolio piece meant to demonstrate real system design.
I went with the reverse index because it enforces a clear invariant: a user has at most one active verification token at a time. That's the kind of constraint that's obvious in a design doc and easy to skip in code — which is exactly why it's worth calling out.
Takeaways
A few things I'd tell someone building this for the first time:
- Store hashes, not raw secrets — even for tokens that aren't passwords.
- Match your hash function to your threat model. High-entropy random tokens don't need bcrypt; low-entropy human secrets do.
- Read-then-act sequences need atomicity, whether that's a Postgres unique constraint or a Redis
GETDEL. The same race condition shape shows up across completely different data stores. - Your storage direction should match your access pattern. If you'll ever need to look something up "the other way," design the reverse index before you need it, not after you're stuck mid-feature.
None of this is exotic — it's the kind of thing you only run into by actually building the feature past the happy path. That's been the most useful part of this project so far: the bugs that don't show up until you ask "wait, how would I resend this?"