<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ozoemena John</title>
    <description>The latest articles on DEV Community by Ozoemena John (@obie).</description>
    <link>https://dev.to/obie</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1515737%2F28bf4e64-aafc-49ed-bcb0-3cf5c2d7430e.png</url>
      <title>DEV Community: Ozoemena John</title>
      <link>https://dev.to/obie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/obie"/>
    <language>en</language>
    <item>
      <title>Authentication from scratch</title>
      <dc:creator>Ozoemena John</dc:creator>
      <pubDate>Tue, 22 Sep 2026 16:08:29 +0000</pubDate>
      <link>https://dev.to/obie/authentication-from-scratch-1n99</link>
      <guid>https://dev.to/obie/authentication-from-scratch-1n99</guid>
      <description>&lt;p&gt;For those of you who've built authentication from scratch in a JavaScript/Node.js application, what part of the implementation gave you the most trouble—not the theory, but the actual engineering? Was it things like session management, refresh tokens, email verification, password recovery, MFA, OAuth, rate limiting, preventing account enumeration, securely handling tokens, or simply keeping all the pieces working together?&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>development</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>I Wrote 238 Tests Against My Own Auth Package and Found 4 Real Bugs</title>
      <dc:creator>Ozoemena John</dc:creator>
      <pubDate>Wed, 19 Aug 2026 00:20:36 +0000</pubDate>
      <link>https://dev.to/obie/i-wrote-238-tests-against-my-own-auth-package-and-found-4-real-bugs-4n85</link>
      <guid>https://dev.to/obie/i-wrote-238-tests-against-my-own-auth-package-and-found-4-real-bugs-4n85</guid>
      <description>&lt;p&gt;I'd already done a lot right by the time I started writing tests for &lt;a href="https://github.com/jon-ozo/beaver-auth" rel="noopener noreferrer"&gt;Beaver-Auth&lt;/a&gt;. Every module had gone through multiple rounds of deliberate review. Enumeration protection, hashed tokens, refresh rotation, TOTP replay defense — the design was solid, and I knew it was solid, because I'd thought hard about every piece of it.&lt;/p&gt;

&lt;p&gt;Then I wrote 238 tests against the actual code, and found 8 real bugs. Some of them were the kind that would have silently broken production on day one.&lt;/p&gt;

&lt;p&gt;This post isn't about the bugs specifically — it's about the gap between "I reviewed this carefully" and "this is shippable," and why that gap is bigger than most of us assume, even when the reviewing was genuinely careful.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Passing tests" and "shippable" are different claims
&lt;/h2&gt;

&lt;p&gt;Here's the trap I nearly walked into: I'd built a solid test suite covering the core auth flows — registration, login, verification — and every test passed. It felt done. But passing tests only tell you the code does what the &lt;em&gt;tests&lt;/em&gt; expect. If the tests were written from the same mental model as the code, they'll happily confirm a bug is correct behavior, because both the code and the test agree on the same wrong assumption.&lt;/p&gt;

&lt;p&gt;The fix wasn't "write more tests." It was &lt;strong&gt;testing against the real, integrated system&lt;/strong&gt; — not a hand-built mock of my own logic, and not testing modules in isolation from what actually calls them. A few of the bugs below only surfaced because a test exercised the real dependency chain instead of assuming it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: TypeScript let an argument-shift bug compile clean
&lt;/h2&gt;

&lt;p&gt;This is the one that scared me most. Beaver-Auth dispatches background work (like sending a verification email) through a &lt;code&gt;TaskDispatcher&lt;/code&gt; interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;TaskDispatcher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;taskName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;onFailure&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default implementation had drifted to a different signature — missing the &lt;code&gt;payload&lt;/code&gt; parameter entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what the class actually had:&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;taskName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onFailure&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every caller still invoked it with all four arguments, matching the &lt;em&gt;interface's&lt;/em&gt; shape. Positionally, that meant the &lt;code&gt;payload&lt;/code&gt; object — a plain data object — landed in the &lt;code&gt;handler&lt;/code&gt; slot, where the code expected a function. The real &lt;code&gt;handler&lt;/code&gt; function landed in &lt;code&gt;onFailure&lt;/code&gt;'s slot. The real &lt;code&gt;onFailure&lt;/code&gt; was silently dropped.&lt;/p&gt;

&lt;p&gt;Run this, and &lt;code&gt;handler()&lt;/code&gt; throws &lt;code&gt;TypeError: handler is not a function&lt;/code&gt; — on literally every dispatch, in every deployment using the default dispatcher, which is the zero-config default nearly everyone would be using. Every verification email. Every password reset email. Silently, forever.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tsc --noEmit&lt;/code&gt; reported zero errors. Not a warning, nothing. Why? TypeScript checks method parameters &lt;em&gt;bivariantly&lt;/em&gt; by default — permissively enough that a function with fewer parameters can satisfy an interface expecting more, especially when one of the mismatched parameter types is &lt;code&gt;unknown&lt;/code&gt; (which structurally accepts anything). The type system had a real hole here, and nothing about writing careful code would have caught it — only &lt;em&gt;running&lt;/em&gt; the actual dispatch path caught it.&lt;/p&gt;

&lt;p&gt;The fix was a one-line signature correction. The lesson was: &lt;strong&gt;a compiling type signature is not proof the shapes actually agree at the call site.&lt;/strong&gt; I added a regression test that exercises this exact path through the real dispatcher (not a test double) specifically so this can never silently regress again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: A feature that looked complete but had no way to actually be called
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LoginEngine&lt;/code&gt; had a &lt;code&gt;logoutJwtSession(familyId: string)&lt;/code&gt; method — revoke a refresh-token family, kill a JWT session's ability to mint new access tokens. Clean, well-tested in isolation.&lt;/p&gt;

&lt;p&gt;Except: nothing, anywhere in the public API, ever returned a &lt;code&gt;familyId&lt;/code&gt; to the caller. Login returned &lt;code&gt;{ status: 'success-jwt', user, accessToken, refreshToken }&lt;/code&gt; — no &lt;code&gt;familyId&lt;/code&gt;. A consumer integrating this package had &lt;em&gt;no way to obtain the one piece of information the logout function required.&lt;/em&gt; The feature was fully implemented and completely unreachable.&lt;/p&gt;

&lt;p&gt;This is the kind of gap that unit tests of &lt;code&gt;logoutJwtSession&lt;/code&gt; in isolation will never catch — you'd construct the test by directly passing in a &lt;code&gt;familyId&lt;/code&gt; you already know, exactly like the broken consumer flow never could. It only surfaced once I wrote an integration test that role-played an actual consumer: log in, then try to log out using &lt;em&gt;only what the login response gave you.&lt;/em&gt; That test couldn't be written without hitting the gap immediately.&lt;/p&gt;

&lt;p&gt;The fix: thread &lt;code&gt;familyId&lt;/code&gt; through the &lt;code&gt;LoginResult&lt;/code&gt; type and every place it's constructed. Small change, but only findable by testing the &lt;em&gt;usage path&lt;/em&gt;, not the unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: My own error type leaked past its own contract
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ValidationEngine&lt;/code&gt; is documented — by its own exported &lt;code&gt;ValidationError&lt;/code&gt; class — to be the only kind of error it throws. Underneath, it delegates to an internal payload-normalization step that has its own error type, &lt;code&gt;NormalizationError&lt;/code&gt;, used for resource-limit violations (someone sending a maliciously deep or wide payload).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NormalizationError&lt;/code&gt; was never wrapped. If normalization threw, &lt;code&gt;ValidationEngine&lt;/code&gt;'s public method let it propagate raw — an internal, &lt;em&gt;not even exported from the package&lt;/em&gt;, error type. Any consumer following the documented pattern —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validateInputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* handle it */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— would have that check silently fail exactly when a malicious payload tripped the depth/size limits. The one case where catching it correctly mattered most was the one case the contract didn't actually cover.&lt;/p&gt;

&lt;p&gt;This is a good example of a bug that's invisible from reading the "happy path" code and only shows up when a test deliberately tries to break the &lt;em&gt;documented contract&lt;/em&gt;, not just the &lt;em&gt;documented behavior&lt;/em&gt;. I added a test that specifically threw a bad enough payload to trigger the internal limit, and asserted the thrown error was &lt;code&gt;instanceof ValidationError&lt;/code&gt; — it wasn't, until I fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: A security fix that quietly stopped working under realistic load
&lt;/h2&gt;

&lt;p&gt;The rate limiter's token-bucket implementation stores bucket state with a TTL, so idle buckets eventually get cleaned up. The TTL was a fixed &lt;code&gt;refillRateMs * 2&lt;/code&gt;. That number has nothing to do with how long a bucket actually takes to refill from empty — which is &lt;code&gt;maxTokens * msPerToken&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Whenever &lt;code&gt;maxTokens / refillAmount&lt;/code&gt; exceeded 2 (a completely ordinary configuration — say, 10 tokens refilling one at a time), the cache entry expired and got evicted &lt;strong&gt;well before&lt;/strong&gt; the bucket would have naturally refilled. The practical effect: an attacker who went quiet for just over the (too-short) TTL got treated as a brand-new identifier on their next request — a fresh, full bucket, essentially resetting the rate limit for free.&lt;/p&gt;

&lt;p&gt;This one needed a very deliberately constructed test to actually prove: advance simulated time past the &lt;em&gt;old, buggy&lt;/em&gt; TTL but nowhere near the &lt;em&gt;true&lt;/em&gt; refill time, and check whether the bucket reports a nearly-full state (bug present) or a correctly-partial one (bug fixed). Both outcomes are plausible-looking numbers — you can't catch this by eyeballing the code, only by doing the arithmetic and asserting the exact expected value under controlled, fake time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath all of these
&lt;/h2&gt;

&lt;p&gt;None of these bugs were "obviously" wrong on a read-through — I'd read through all of this code multiple times already. What they had in common:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They lived at integration boundaries&lt;/strong&gt;, not inside a single function's logic. The dispatcher bug was an interface/implementation mismatch. The &lt;code&gt;familyId&lt;/code&gt; bug was a missing field crossing a return-type boundary. The &lt;code&gt;NormalizationError&lt;/code&gt; leak was one module's contract silently depending on another's internals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They were invisible without exercising the real call path.&lt;/strong&gt; A hand-rolled mock of "what I assume the dispatcher does" would have passed every test, because I would have mocked it to match my own (wrong) assumption about its shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The type system helped, but wasn't sufficient.&lt;/strong&gt; TypeScript caught plenty elsewhere in this project. It specifically didn't catch the one bug that would have broken the most things, because the unsoundness in method-parameter checking is a known, documented trade-off in the language — not a tooling failure, just a gap worth knowing about.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this process
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test against the real dependency graph, not a mock of your own mental model of it.&lt;/strong&gt; If your test double was written by the same person who wrote the bug, it will happily agree with the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write integration tests that role-play an actual consumer&lt;/strong&gt;, using only what your public API actually gives them — not what you, the author, happen to already know. This is what caught the unreachable &lt;code&gt;logoutJwtSession&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test your contracts, not just your happy paths.&lt;/strong&gt; "This function is documented to only throw X" is a claim that needs its own test, deliberately trying to make it throw something else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you fix something subtle, write the test that would have caught it &lt;em&gt;before&lt;/em&gt; the fix&lt;/strong&gt; — check it actually would have failed against the old code. A regression test that can't prove it would have caught the regression isn't actually a regression test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"It compiles" and "the tests pass" are both necessary and neither is sufficient.&lt;/strong&gt; Verified against the real, wired-together system is the only claim actually worth making before you tell people something is shippable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;238 tests, 4 real bugs, and — as far as I know right now — a package I trust considerably more than the one I had before I started. Not because I got smarter partway through. Because I finally stopped grading my own homework.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Beaver-Auth is an open-source TypeScript auth package built on Node's built-in crypto, with a public test suite you can read and run yourself: &lt;a href="https://github.com/jon-ozo/beaver-auth/tree/Main/apps/test-app" rel="noopener noreferrer"&gt;github.com&lt;/a&gt;. If you find a bug I missed, that's genuinely the best possible outcome — &lt;a href="https://github.com/jon-ozo/beaver-auth/blob/Main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;here's how to report it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>node</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why Your Login Response Time Is a Security Leak</title>
      <dc:creator>Ozoemena John</dc:creator>
      <pubDate>Sun, 09 Aug 2026 15:38:01 +0000</pubDate>
      <link>https://dev.to/obie/why-your-login-response-time-is-a-security-leak-5fij</link>
      <guid>https://dev.to/obie/why-your-login-response-time-is-a-security-leak-5fij</guid>
      <description>&lt;p&gt;You did everything right. Your login endpoint returns a generic "invalid credentials" for both a wrong password and a nonexistent email. No information leaked — right?&lt;/p&gt;

&lt;p&gt;Not quite. There's a channel most of us never think to close: &lt;strong&gt;how long the response took to come back.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak, made concrete
&lt;/h2&gt;

&lt;p&gt;Here's roughly what a "correct-looking" login handler does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUserByEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid-credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid-credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both failure paths return the exact same response shape. Looks airtight. But watch what actually happens on the wire:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email doesn't exist:&lt;/strong&gt; one database lookup, then an immediate return. Fast — a few milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email exists, password is wrong:&lt;/strong&gt; the database lookup, &lt;em&gt;plus&lt;/em&gt; a full password verification. If you're using bcrypt, scrypt, or Argon2 (and you should be), that step is &lt;strong&gt;deliberately slow&lt;/strong&gt; — often 50–200ms, by design, to resist brute-forcing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An attacker doesn't need to read your response body at all. They just need a stopwatch. Submit a candidate email with any password, measure the response time, and a gap of 100+ ms tells them the account exists — invisibly, at scale, across thousands of addresses, with zero errors logged that look unusual (they're all just "failed logins").&lt;/p&gt;

&lt;p&gt;This is a real, well-known class of attack called a &lt;strong&gt;timing side-channel&lt;/strong&gt;, and it's one of the easier ones to actually pull off, because HTTP response timing is something every client can measure for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instinctive fix doesn't fully work
&lt;/h2&gt;

&lt;p&gt;The first thing most people try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "compare against something" so the timing looks similar&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DUMMY_HASH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid-credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better! Now both branches do &lt;em&gt;some&lt;/em&gt; expensive hashing work. But this only gets you &lt;em&gt;closer&lt;/em&gt;, not &lt;em&gt;equal&lt;/em&gt; — and "closer" is exactly the gap a patient attacker exploits. Real systems have jitter: network latency, GC pauses, database connection pool contention. A few milliseconds of consistent difference, averaged over a few hundred requests per candidate email, is still statistically detectable. You've raised the cost of the attack, not eliminated it.&lt;/p&gt;

&lt;p&gt;There's also a second, easy-to-miss version of this same bug: &lt;strong&gt;inconsistent response &lt;em&gt;shape&lt;/em&gt;, not just timing.&lt;/strong&gt; If your registration endpoint returns &lt;code&gt;{ status: 'success', user: {...} }&lt;/code&gt; for a new email but &lt;code&gt;{ status: 'success-pending-verification' }&lt;/code&gt; (no user object) for one that already exists — congratulations, you've built a perfectly reliable, zero-timing-analysis-required account enumeration oracle. This is arguably worse than the timing leak, because it doesn't even require statistics — one request tells you everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works: a response floor
&lt;/h2&gt;

&lt;p&gt;The fix that closes this properly isn't "make the branches equally fast" (hard to guarantee) — it's "&lt;strong&gt;pad every branch up to a fixed minimum time&lt;/strong&gt;," so the &lt;em&gt;total&lt;/em&gt; response time is constant regardless of which internal path executed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;holdToFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseFloorMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap this around &lt;em&gt;every&lt;/em&gt; exit path of the endpoint — success, wrong password, nonexistent user, even unexpected server errors — and the observable timing collapses to "at least &lt;code&gt;responseFloorMs&lt;/code&gt;," full stop. It doesn't matter if the actual work took 3ms or 80ms; the client always waits until the floor. No amount of statistical averaging recovers a timing signal that was never there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;executePasswordStage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUserByEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// still do the expensive comparison, so this path costs roughly&lt;/span&gt;
      &lt;span class="c1"&gt;// the same as the real one — belt AND suspenders with the floor&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;STATIC_DUMMY_HASH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;holdToFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid-credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;holdToFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid-credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ...success path also goes through holdToFloor before returning&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;holdToFloor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system-error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the dummy-hash comparison is &lt;em&gt;still there&lt;/em&gt; — the floor is defense in depth, not a replacement for doing real, comparable work on both paths. Belt and suspenders.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response-shape fix, applied to registration
&lt;/h2&gt;

&lt;p&gt;The same principle applies to &lt;em&gt;what&lt;/em&gt; you return, not just &lt;em&gt;when&lt;/em&gt;. For registration specifically, this means every branch — new email, existing email, even with enumeration protection explicitly disabled — has to converge to identical shapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protectAgainstEnumeration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// do the SAME unit of real work as the new-user branch,&lt;/span&gt;
    &lt;span class="c1"&gt;// so there's no synchronous cost difference to leak either&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hashPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;DUMMY_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success-pending-verification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email-already-exists&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// new user path&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...})&lt;/span&gt;
&lt;span class="c1"&gt;// fire-and-forget the verification email — deliberately NOT awaited,&lt;/span&gt;
&lt;span class="c1"&gt;// so its cost never shows up in this response's timing at all&lt;/span&gt;
&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendVerificationEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success-pending-verification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the existing-email and new-email paths return the exact same status, with no extra fields that differ. An attacker probing your &lt;code&gt;/register&lt;/code&gt; endpoint with a list of candidate emails gets back one indistinguishable response, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off you're actually making
&lt;/h2&gt;

&lt;p&gt;This isn't free. A fixed response floor means &lt;strong&gt;every legitimate user waits at least that long too&lt;/strong&gt;, even on your fastest possible code path. At scale, that's real, deliberately-added latency, and in serverless environments specifically, it's &lt;em&gt;billed&lt;/em&gt; latency — you're paying for milliseconds you didn't strictly need, on every single request.&lt;/p&gt;

&lt;p&gt;The honest way to think about it: pick the floor based on your &lt;em&gt;slowest legitimate branch's&lt;/em&gt; realistic p95, not an arbitrary round number. If your real success path takes 60–80ms under load, a 150ms floor is padding a fixed, bounded amount — not multiplying your latency. Measure it, don't guess it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;"Return the same error message" is necessary but nowhere near sufficient for an auth endpoint that needs to resist enumeration. The full checklist looks more like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Same response shape&lt;/strong&gt;, in every branch, with no extra/missing fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparable real work&lt;/strong&gt; on every branch (a dummy hash comparison when there's no real one to do).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard floor on total response time&lt;/strong&gt;, applied after everything else, covering success &lt;em&gt;and&lt;/em&gt; error paths alike.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skip any one of the three and you've left a measurable signal on the table — and "measurable" is all a patient attacker needs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post describes design decisions from &lt;a href="https://github.com/jon-ozo/beaver-auth" rel="noopener noreferrer"&gt;beaver-auth&lt;/a&gt;, an open-source TypeScript auth package built on Node's built-in crypto. If you're building auth from scratch, beaver-auth's &lt;code&gt;RegistrationEngine&lt;/code&gt; and &lt;code&gt;LoginEngine&lt;/code&gt; implement all three of the fixes above by default — response floor, enumeration-safe response shapes, and dummy-hash comparisons aren't opt-in extras, they're how the package behaves out of the box.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>authjs</category>
      <category>backend</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
