Flow
Decide once, then pass the answer along
1Compare to the shared key
Cheap. A match returns shared-key and skips the hash.
2Check the stored password
The slow hash runs here, once, only after the cheap check missed.
3Return one answer
Shared key, password, or none.
4Admit and label from that answer
No second hash to work out the label.
Every correct password made the gateway's admin sign-in run the password hash twice. The commit that cut the second call also made each call about four times slower.
Two ways in, one of them slow
One of the API gateways in my stack has an admin side. You get in with a shared bootstrap key from config, which is quick to check. Or you use an admin password I'd added the day before, stored as a scrypt hash. Scrypt is slow on purpose, so a stolen hash is hard to guess against. But my server pays that cost too, every time it checks. So it matters how many times one request can make it run.
I did a security pass with an agent, and it went in as one commit. It raised the scrypt cost to the usual published minimum, about four times the old work. It also cut a second scrypt call from sign-in.
The yes-or-no answer that caused it
The admin check only said yes or no. Then the code that labels the request needed to know which secret had matched. The check had thrown that away, so the label code ran the slow hash again to find out.
So a correct password cost two hashes. A correct bootstrap key cost one wasted hash. A wrong secret cost one, which is the only one I ever wanted. The admin web page's login had the same double call.
One answer, used twice
The check now says which kind of secret it saw, or none. The caller uses that one answer to let the request in and to label it:
kind = which_secret(secret) # "shared-key", "password", or None
Now it's at most one hash per request, and none when the bootstrap key matched.
What the stronger hash costs now
A correct password used to cost two normal hashes. Now it's one hash at about four times the cost. That's still about double the old price, which I'm fine with. Paying it twice was the problem.
The same commit closed some smaller gaps. The rotation route now compares secrets in constant time. Admin routes got a rate limit before any parsing or login work. Error replies stopped echoing the caller's address. Logs stopped saving full URLs with query strings. And bad percent-encoding in a route now gets a client error.
A memory cap the new cost probably trips
The commit had no tests, and nothing shows a password sign-in running on the real runtime. Scrypt uses more memory as the cost goes up, and the runtime has a memory cap per call. My reading of the docs is that the new cost needs about twice that cap, so I expect the call would throw. The bootstrap key skipped scrypt after this commit, so checking with it would have looked fine. A few weeks later I shipped a one-line change that raised the cap.