fix(auth): hash one-time codes and stop confirming which emails exist - #100
Merged
udaycodespace merged 4 commits intoAug 28, 2026
Merged
Conversation
Registration and password reset generated a six-digit code with Math.random() and wrote it to the user document verbatim. `select: false` and the admin projection from udaycodespace#54 control what a query returns; neither changes what is on disk. Anything with read access to the collection — a backup, a replica, a mongodump in a CI artefact — carried a working credential for every account with a pending code, usable without ever seeing the mailbox. verify-otp answered 404 "User not found" for an unknown address and 400 for a wrong code, and reset-password did the same. Two status codes on an unauthenticated endpoint is an enumeration oracle: one request confirms or denies any address. forgotPasswordController in the same file already answered uniformly, so the project had already decided this matters. utils/otpCredentials issues codes from crypto.randomInt, stores only the bcrypt hash, compares through bcrypt, and returns one of OK, INVALID, EXPIRED, LOCKED or MISSING with an attempt count. The callers answer all four failures with the same status and the same body, after a decoy bcrypt comparison on the paths where there is nothing to compare — without that the absence of a comparison is the new oracle. A credential now dies when it is used, when it expires, when a new one supersedes it, and when it reaches five failed attempts; a locked-but-live code is a code somebody can keep working on. Requesting a new code resets the counter, so a locked-out account can recover. A verified account has any leftover code cleared, and a password reset clears the pending verification code with it. otpAttempts and resetTokenAttempts are select: false and carry no default, so an absent counter means no pending code and $unset means gone. Both are added to toJSON and to SENSITIVE_USER_FIELDS. No migration: a plaintext code left from before fails bcrypt.compare and the user asks for a new one, which is the right direction for a credential change. Closes udaycodespace#95
Contributor
Author
Overlap with #78, worth deciding before either landsBoth merge cleanly into
Whichever lands second has to take on the other's shape. Concretely:
I am happy to rebase whichever one you prefer to take second — just say which order you want and I will push the resolution. My suggestion is this PR first: #78's resend is a small handler and adapting it to |
38 tasks
udaycodespace
self-requested a review
August 24, 2026 15:35
Three conflicts in userControllers.js, all of them the same shape: main normalised the account lookups through buildEmailFilter for udaycodespace#72 while this branch was changing what those lookups select. Both sides are wanted, so both are kept. verify-otp and reset-password now resolve the account through buildEmailFilter and select the attempt counters this branch adds. The import block keeps otpCredentials and accountIdentity.
Two independent limits guard /verify-otp and both default to five: the per-account failure throttle from udaycodespace#63, which answers 429 and locks the address, and the per-credential attempt counter this branch adds, which answers 400 and burns the code. The throttle is middleware, so at equal limits it fires first and the counter is never reached. Both are wanted in production, and the outcome is the same either way — the code stops working. But this test's subject is the counter, so it gives the throttle headroom rather than loosening the assertion to "refused, somehow". A second test pins the default interaction, so the ordering is stated rather than implied.
Main gained the resend route (udaycodespace#73) while this branch was open, and the two changes land on the same three functions. The conflicts were not competing implementations, they were two halves that had to be put together. Main routes every code through issueVerificationOtp — registration and /resend-otp both — with a cooldown derived from otpLastSentAt. This branch replaced plaintext storage with a bcrypt hash and an attempt counter. So the hashing moved inside issueVerificationOtp rather than staying inline in registerController. It is now the one place a verification code is issued, which is what closes registration and the resend route in the same edit: it stores credential.hash, resets otpAttempts, keeps otpLastSentAt, and mails the plaintext it never writes down. userModel keeps both new fields — otpAttempts from here, otpLastSentAt from main — and deletes both in toJSON. verifyOtpController keeps the hashed comparison. Main's side compared a plaintext column that no longer exists, so there was nothing to preserve there. What udaycodespace#73 added on top of it is kept: `canResend` still rides on the failure response so VerifyEmailPanel keeps offering the button. It is constant rather than derived, which is a deliberate change to udaycodespace#73's version. Deriving it from user.otpLastSentAt would put the enumeration oracle straight back — an unknown address has no send time, so it would answer differently from a real one on cooldown, and this response is uniform on purpose. The cooldown is still enforced and still reported, by /resend-otp, which owns it and answers 429 with the exact wait; VerifyEmailPanel already reads retryAfterSeconds off that response. The existing test asserting the two responses are byte-identical passes. One test updated: otp-resend asserted user.otp matched /^\d{6}$/, which was the defect. It now asserts the mail carries the code, the database carries a bcrypt hash of it, and the two verify against each other. Verified end to end: register mails 898200 and stores $2a$10$..., a wrong code and an unknown address answer identically, the right code verifies, and login succeeds.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every pending verification and password-reset code was stored in the
userscollection as the six digits the endpoint accepts, and/verify-otpand/reset-passwordanswered differently for an unknown address than for a wrong code. This hashes the credentials, makes every failure indistinguishable, and bounds guessing per account.Related Issue
Closes #95
What changed?
backend/utils/otpCredentials.js(new)generateCode()crypto.randomInt, notMath.random()issueCredential(){ code, hash, expiresAt, attempts: 0 }— the plaintext is returned once for the email and never persistedverifyCredential(stored, candidate){ status, attempts, shouldClear }isWellFormedCode()burnComparison()isFailure(status)OK— fails closed for a status added laterverifyCredentialreturnsOK,INVALID,EXPIRED,LOCKEDorMISSING. The callers answer all four failures identically; the distinction exists for the code path, never for the client.shouldClearis the invalidation rule in one place: a credential dies when it is used, when it expires, and when it reachesMAX_ATTEMPTS. That last one matters — a locked-but-live code is a code somebody can keep working on. A missing,null,NaNor non-numericexpiresAtcounts as expired rather than valid.burnComparisonis the part that is easy to omit. Uniform responses are only uniform if they take the same time; without a decoy comparison on the "no such account" path, the absence of a bcrypt call becomes the new oracle. The decoy hash is computed once at module load.Schema —
otpandresetTokenhold a bcrypt hash.otpAttemptsandresetTokenAttemptsare new,select: false, and deliberately carry no default: an absent counter means no pending code, and$unsethas to mean gone. Both are added totoJSONand toadminController.SENSITIVE_USER_FIELDS, and the admin reset-password$unsetclears the counter with the token.Controllers
registerstorescredential.hash; the plaintext reaches the mailbox and nothing else.verify-otpanswers400 "Invalid or expired OTP"for an unknown address, a wrong code, an expired code, a locked account and a missing code — same status, same body, same work. A verified account has any leftover code cleared. On success,isVerifiedand the$unsetare one atomic update.forgot-passwordstores the hash and resets the attempt counter, so an account that hit the limit can recover by asking for a new code. Its uniform response is unchanged.reset-passwordanswers400for an unknown address where it used to answer404 "User not found", and on success clears the reset credential and any pending verification code. It still setsisVerified— that was already the behaviour and is now stated in a comment: holding the code proves control of the mailbox.error.messageto the client.Type
Areas touched
Migration: none needed. A plaintext code left over from before this ships simply fails
bcrypt.compare, the user is told the code is invalid — which it now is — and requests a new one. This fails closed, which is the right direction for a credential change.Testing
npm testinbackend/— 257 passing, up from 234 onmain. 23 new tests, including integration tests againstmongodb-memory-server.npm run lintis a frontend-only script and this PR touches no frontend files; it has never passed onmain(69 problems, almost all pre-existingno-unused-varson React imports).Test steps
a@example.com.db.users.findOne({email:"a@example.com"}, {otp:1})→ a$2a$10$…hash. Onmainthis is the six digits that were just emailed.curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:5000/api/user/verify-otp -H 'Content-Type: application/json' -d '{"email":"nobody@example.com","otp":"000000"}'→400. Onmain,404.a@example.com→400, with an identical body. The oracle is gone.otpis gone from the document.POST /api/user/forgot-password→ a fresh hash andresetTokenAttemptsback to 0.Screenshots
Edge cases checked
Other edge case details
null, an object — costs an attempt and never matches.null,NaNor non-numeric expiry is treated as expired, never as valid.Checklist
CONTRIBUTING.mdNotes
docs/issue-95-otp-credential-storage.mdhas the full write-up, including why the decoy comparison is load-bearing.This is complementary to #63, not a substitute for it.
MAX_ATTEMPTSbounds guesses against one account regardless of how many source addresses they come from; the limiter in #63 bounds one source regardless of how many accounts it targets. Both are wanted.