Account Recovery

Does obfuscation break account recovery options?

A password-reset link is the one credential you hand to somebody who has, by definition, just proved they cannot authenticate. Everything that makes it safe rather than a permanent skeleton key is a property name on an options object: how long it lives, whether it survives its own use, whether redeeming it ends everyone else's session, and how the token is actually compared. The library reading those names was installed, not built. We protected a file that configures one, renamed the names a group at a time, and counted the accounts that changed hands.

What the sample actually does

The file configures a password-reset flow the way a careful team configures it. The token lives fifteen minutes rather than the library's default day. It is single use, so redeeming it burns it. Resetting a password invalidates every other session. The endpoint is enumeration-safe, so an address with no account gets exactly the same reply as one that has. A minimum token entropy is set, so a short legacy token is refused. And the application supplies its own constant-time comparison against the stored hash.

Each of those is then exercised against a request that only that guard can refuse. The replayed link is inside its freshness window and carries the correct token, so nothing but the single-use rule can stop it. The nine-hour-old link has never been used and matches, so only the lifetime rule can. Getting this separation right took a correction mid-pass: the first version of the replay fixture was twenty minutes old against a fifteen-minute window, so the lifetime rule refused it first and hid the result the single-use rule was there to produce. An arm that is never reached reports no change, truthfully and uselessly.

Before any protected arm was read, each option was deleted from the sample's own configuration and the output compared. Every option moved the result except the one deliberately pinned equal to the library's default, which is the control. That step is cheap and it is the only reason the arms below mean anything.

The recovery helper is copied into the measurement directory unprotected, because that is the shape of the real thing. Your bundle is rebuilt when you protect it; the package in node_modules is not, and it keeps reading the property names it has always read. Its defaults are the permissive ones this family of helpers ships when handed an empty configuration: a day-long token, no replay protection, sessions left alive, a reply that distinguishes known addresses from unknown ones, a thirty-two-bit entropy floor, and a token test that asks whether the token is a non-empty string.

Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file. Nothing below is caused by protection on its own. Every result required member renaming aimed at the names, which is what the MemberRegexp option exists to scope.

The link that worked twice

Renaming the single-use option took the measured line from replayed-spent-link=refused(already-redeemed(2026-08-17T11:57:00Z)) to replayed-spent-link=ACCEPTED(accepted) user=u-2002, and the reported policy from single-use=true to single-use=false.

The scenario this models is ordinary rather than exotic. Somebody had brief access to a mailbox - a shared family tablet, a session left open on a work machine, a support inbox with too many members - and the victim noticed, reset their own password, and moved on. The reset link in that mailbox is supposed to be a spent ticket. Without replay protection it is a standing invitation, valid for the rest of its lifetime, that survives the very action taken to lock the attacker out.

The freshness rule failed the same way and widens the window it applies to. Renaming the lifetime option reverted it from fifteen minutes to the library's day, and a link harvested from a mailbox backup nine hours earlier verified: nine-hour-old-link=refused(expired(age=9h)) became ACCEPTED(accepted) user=u-3003. Mailbox backups, mail-scanning appliances and archived support threads are exactly where old links accumulate.

Neither option protects the other. Renaming both produced both outcomes, and renaming the whole configuration block at once - which is what a broad pattern actually does - produced every outcome in this article simultaneously.

The timestamp that switched off the token check

The sharpest arm of this area is not a security option at all. It is issuedAt, a plain timestamp field on the stored record, and renaming it alone took wrong-token=refused(token-mismatch) to wrong-token=ACCEPTED(accepted(age-unknown)) user=u-1001.

The mechanism is control flow rather than policy. The redemption path parses the issue time to compute an age. When the field cannot be read the parse yields not-a-number, and the code takes its documented "age cannot be determined" branch - which returns an acceptance early, before the token comparison further down the function is ever reached. Losing a field that carries no security meaning routed the request around the guard that carries all of it.

This is worth sitting with, because it defeats the usual way of deciding what a rename pattern is allowed to touch. Reviewers protect the names that look like security: the verifier, the flags, the thresholds. A timestamp reads as metadata. Here the timestamp was the load-bearing one, because an unparseable value put the function on a branch whose author never imagined it would be reached with an attacker-supplied token in hand.

The general form: any "we could not determine X" branch is a second, quieter policy decision, and it is usually written to be permissive because it was designed for a benign case such as a legacy record. When a rename makes X undeterminable for every request, that branch stops being an edge case and becomes the main path.

The record you build and the record you are handed

This pass measured a contrast that corrects an intuition worth naming, because the guess most people make about it is backwards.

The sample holds two stored records with identical contents. One is an object built in this file. The other is the result of parsing what the store handed back - a row, a cached blob, a document written by whichever build issued the token. Renaming the usedAt field moved only the first: replayed-spent-link-in-process went from refused to ACCEPTED, while the parsed record was refused exactly as before.

The reason is that renaming rewrites names in code and never the characters inside data. A key in a JSON document is text; it arrives spelled the way it was written and the installed library reads it without difficulty. A key in an object literal your code builds is a name, and it moves. So the exposure is not in your persisted data, which is inert and safe. It is in the record you construct in code and hand across the boundary - and that is the one nobody worries about, because it never leaves the process in a form anyone thinks of as serialised.

The practical consequence is that the risky objects are the ones assembled a few lines before the call. Anything you build to hand to an installed guard is an interface, spelled in property names, with a consumer that was compiled separately.

The three that fail quietly and the one that fails loudly

Renaming the enumeration-safe option restored the oracle. The reply for a real address and the reply for a stranger's diverged, and the measured line addresses-distinguishable went from false to true. That is a bulk-harvest primitive: it turns a list of addresses into a list of your customers, at whatever rate you allow requests.

Renaming the session-invalidation option left the attacker signed in. sessions-alive-after-reset=(none) became victim-phone,ATTACKER-LAPTOP, and attacker-still-signed-in went from false to true. This is the arm that most directly defeats the user's own remedy: the whole reason a victim resets a password is to evict somebody, and the eviction is a separate option from the reset.

Renaming the entropy floor reverted it from a hundred and twenty-eight bits to thirty-two, and a forty-bit legacy token was accepted: legacy-40-bit-token=refused(need=128 got=40) became ACCEPTED(need=32 got=40). Forty bits is guessable by anybody willing to spend the requests.

Renaming the comparison did not switch checking off - it downgraded it. The application's constant-time compare was replaced by the library's builtin, which asks whether the presented token is a non-empty string, and a deliberately wrong token verified. The reported verifier moved from caller-supplied(constant-time compare of the hash) to library-builtin(presence only), which is the only line in the capture that names the substitution. This is the nineteenth library across eight passes in which a caller-supplied guard was replaced by a weaker builtin rather than removed, and it defeats the obvious audit: token verification is still configured, still enabled, still running, and answering a different question.

The loud direction, for contrast, is renaming the stored record's own fields as a group. Those are read and compared, so the flow refuses everybody including the genuine customer - and a reset flow that refuses everybody is a support queue within the hour. The dangerous direction is inward, where a renamed option name is silently ignored and the library's permissive default applies.

What to do about it

Scope the rename. RenameMembers takes a MemberRegexp, and the names to keep out of it are the option keys and result fields of anything you did not compile yourself. That is the entire fix for every arm in this article.

Then confirm it with probes rather than with a configuration review, because every arm here left the configuration looking correct. Redeem a reset link twice and require the second attempt to fail. Present a deliberately wrong token and require a refusal. Ask for a reset for an address that does not exist and diff the response against one that does, byte for byte. Reset a password while signed in on a second device and require that device to be logged out. Four probes, all of which fail loudly when a name has moved.

The one to add specifically because of this pass: feed the redemption path a record with an unreadable issue time and require a refusal rather than an acceptance. That is the branch that skipped the token comparison, and it is invisible to every other check.

Frequently asked questions

Does protecting my JavaScript break password-reset flows on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result in this article required member renaming pointed at property names.

What was the worst result?

Renaming a plain timestamp field on the stored record. It made the issue time unparseable, which put the redemption on its "age cannot be determined" branch - and that branch returns an acceptance before the token comparison is reached. A deliberately wrong token was accepted.

Is my data in the database at risk from renaming?

No, and the measurement showed the opposite of what most people expect. A key in stored JSON is text, so it arrives spelled the way it was written and the installed library reads it fine. The object your code builds in memory and hands to that library is the one whose keys move.

Does a reused reset link really matter if it expires?

It matters for the whole of its lifetime, and the lifetime is a separate option that can be lost by the same pattern. In the measurement a spent link was accepted again, and separately a link reverted from a fifteen-minute window to the library's default day.

Did the token comparison get switched off?

No, it was downgraded. The application's constant-time compare was replaced by the library's builtin, which confirms the token is a non-empty string. A wrong token verified, and every check of the form "is verification enabled?" still returned true.

Which arms fail loudly enough to catch in testing?

Renaming the stored record's fields as a group. Those are read and compared, so the flow refuses everybody including the genuine customer, and that surfaces within the hour. The dangerous direction is inward, where a renamed option name is silently ignored and a permissive default applies.

What is the smallest change that prevents all of this?

Scope RenameMembers with a MemberRegexp that excludes the option names and record fields of installed dependencies, then run four probes: redeem a link twice, present a wrong token, compare the replies for a known and an unknown address, and reset while signed in elsewhere.

Related reading