<?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>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>
