Skip to content
Suriyaprakash.
Engineering

Field note / Engineering+

Password Hashing, MFA, and Account Security in Depth: Storing Credentials and Defending Accounts

How to store passwords with Argon2id or bcrypt, block breached passwords, throttle logins, design reset codes, and add TOTP or passkey MFA.

Published
Reading time27 min read
Filed underEngineering

Part 1 of this series separated authentication from authorization. Part 2 followed a successful login into a session or a JWT. Part 3 covered the case where you never see the credential at all, because Google or GitHub authenticates the user and hands you a verified identity.

This part covers the case Part 3 deliberately avoided: you decided to hold the credential yourself. A password field is a promise that you will store something derived from a secret, check it on every login, survive a database leak, and handle the flows around it (resets, one-time codes, a second factor) without turning them into a side door.

The examples are small and illustrative (TypeScript, bcryptjs, SQL, curl). Where I say I tested something, I ran it; where a figure comes from a standard, I name the standard. The design is framework-neutral, so you can apply it to Express, Fastify, a TanStack Start app or a Worker.

Quick answer

  • Never store a password. Store a slow, salted, one-way derivation of it. The current OWASP preference order is Argon2id, then scrypt, then bcrypt for legacy systems, with PBKDF2 when FIPS-140 compliance is required.
  • The hash exists for the day your database leaks. It makes each offline guess expensive. It does nothing against someone typing guesses into your login form.
  • Online guessing is a different problem with different defenses: rate limits, uniform error messages, and refusing passwords that already appear in breach corpora.
  • Resets and one-time codes are credentials too. They are usually the weakest path into an account, so they need expiry, an attempt budget, and single use.
  • MFA is the defense for the case where the password is already known to the attacker. Prefer phishing-resistant factors (passkeys or security keys) over codes a user can be tricked into typing into a fake page.
SIGN-UP / PASSWORD CHANGE                      SIGN-IN
-------------------------                      -------
password
  -> length + blocklist + breach check         email + password
  -> slow salted hash (Argon2id / bcrypt)        -> rate limit (per IP+email, per IP)
  -> store hash only                             -> load hash (or a dummy if no account)
                                                 -> verify (same cost either way)
                                                 -> one generic error on any failure
                                                 -> second factor, if enrolled
                                                 -> new session (Part 2)

What you are actually defending against

"Secure passwords" is really four different attacks, and each one is stopped by a different control. Mixing them up is how teams end up with a very expensive hash and an unthrottled login form.

AttackWhat the attacker hasWhat stops it
Offline crackingA copy of your password tableA slow, salted, memory-hard hash; long passwords
Credential stuffingEmail and password pairs leaked from other sitesBreach checks at set time, rate limits, MFA
Online guessingNothing but your login formRate limits, throttling per account and per IP
PhishingA convincing fake of your login pagePhishing-resistant MFA (origin-bound credentials)

The hash only addresses the first row. Everything else in this post is about the other three.

Why hashing, and why a fast hash is the wrong tool

Encryption is reversible: whoever holds the key can recover the password. You never need to recover a password, only to check one, so you want a one-way function.

The catch is that a general-purpose hash like SHA-256 is built to be fast. A fast hash lets an attacker with a leaked table try enormous numbers of guesses per second. A password hashing function is deliberately slow and tunable, so you can raise the cost as hardware improves. The two other ingredients:

  • A unique random salt per password. It means identical passwords produce different stored values and kills precomputed lookup tables. Good libraries generate and embed the salt for you.
  • A work factor (iterations, memory, or both). This is the dial that makes each guess expensive.

A stored bcrypt value carries its own parameters, which is why you can change them later without a schema change:

$2b$12$<22-character salt><31-character hash>
 |   |
 |   +-- cost factor: 2^12 rounds
 +------ algorithm version

$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
          |      |        |
          |      |        +-- memory (KiB), iterations, parallelism
          |      +----------- the parameters travel with the hash
          +------------------ variant

Those shapes are what to look for when you inspect a table. (The values above are placeholders, not real hashes.)

Choosing an algorithm and its parameters

OWASP's Password Storage Cheat Sheet gives a clear order and minimum configurations:

AlgorithmWhenOWASP minimum configuration
Argon2idFirst choice19 MiB memory, 2 iterations, parallelism 1 (equivalent alternatives trade memory for iterations, for example 46 MiB with 1 iteration)
scryptIf Argon2id is unavailableN = 2^17 (128 MiB), r = 8, p = 1
bcryptLegacy systems onlyWork factor of at least 10, and a hard 72-byte input limit
PBKDF2 (HMAC-SHA256)When FIPS-140 compliance is required600,000 iterations

RFC 9106, the Argon2 specification, makes Argon2id its primary variant and gives two parameter sets for general use: a first option of one iteration, four lanes and 2 GiB of memory, and a second option for memory-constrained environments of three iterations, four lanes and 64 MiB. These are larger than the OWASP minimums. The two documents answer different questions: OWASP states a floor that is cheap enough to run on a web server under login load, while the RFC describes a uniformly safe choice when you can afford the memory.

A defensible way to use both:

  1. Pick the algorithm from the table. Argon2id if your runtime supports it well.
  2. Start at the OWASP minimum and raise the cost until a single hash takes as long as your login path can tolerate on your production hardware. A number from a blog post, including this one, is not a substitute for measuring.
  3. Store the parameters with the hash (the formats above do this) so you can raise them later.

Salt, pepper, and what a pepper does not do

A salt is public and per-password. A pepper is a secret shared across passwords, kept outside the database (a secrets store or HSM), so a stolen table alone is not enough to start cracking. OWASP treats it as defense in depth. It also has a real cost: if a pepper is compromised and you must change it, users have to reset their passwords.

A common split is to use a pepper for things derived from random values (session tokens, one-time codes) and rely on the algorithm's cost for passwords. Either is defensible. What matters is that you know which scenario each protects: the pepper helps when only the database leaks, and the work factor helps when everything leaks.

The bcrypt 72-byte trap

bcrypt reads at most 72 bytes of input. The bcryptjs README says so, and adds that the library does not check the length itself; it tells you to call bcrypt.truncates(password) where you need to know. I tested what that means in practice:

import bcrypt from 'bcryptjs'

const a = 'A'.repeat(72) + '-first-tail'
const b = 'A'.repeat(72) + '-different-tail'
const hash = bcrypt.hashSync(a, 10)

bcrypt.compareSync(b, hash) // true: everything after byte 72 is ignored
bcrypt.truncates(a)         // true

Two different passwords verified against the same hash because they shared their first 72 bytes. Two consequences follow:

  • Your length limit has to match the algorithm. A validation contract that accepts passwords up to 128 characters in front of bcrypt promises more than bcrypt delivers: characters past byte 72 add no strength. OWASP's advice is to enforce a maximum of 72 bytes (or lower, if your implementation has a smaller limit) when using bcrypt.
  • Bytes are not characters. UTF-8 uses up to 4 bytes per character. I checked that 25 euro signs are 25 characters but 75 bytes, which is already over the limit.

Pre-hashing the password before bcrypt is the common workaround, and OWASP calls it dangerous (null-byte truncation, and "password shucking" attacks). If you must, it describes an HMAC-based construction with a pepper kept outside the database. The simpler fix is to reject over-length input with bcrypt.truncates, or to choose Argon2id, which has no such limit.

Running it on Workers and edge runtimes

Where your code runs changes which algorithm is practical. Cloudflare's Web Crypto documentation lists PBKDF2 among the supported key-derivation algorithms (alongside HKDF), and does not list Argon2, scrypt or bcrypt. So on Workers you have two choices: PBKDF2 through the native API, or a WebAssembly or pure-JavaScript implementation of the others, such as bcryptjs.

CPU time is the other constraint. Cloudflare's documented limits are 10 ms of CPU per request on the Free plan and, on Paid, 30 seconds by default (configurable up to 5 minutes). A deliberately slow hash will not fit a 10 ms budget, so the plan you deploy on is part of your password design. This is an inference from the documented limits, so measure your own cost on your own plan.

My own check was small: a throwaway Worker run with wrangler dev on my machine derived 256 bits with PBKDF2-HMAC-SHA256 at 100,000, 210,000 and 600,000 iterations, and all three succeeded, the last in roughly 190 ms. That is one local run on one machine. I could not find a documented iteration ceiling for the deployed runtime, and local behavior is not proof of production behavior, so test the exact setting in a deployed Worker before committing to it.

The sign-in path, step by step

Hashing is the easy part. Most account takeovers go through the code around it. Here is a sign-in path that holds up, with the reasoning for each piece. The snippets are illustrative.

One error message for every failure. Use a single Invalid email or password response whether the email is unknown, the password is wrong, or the account is locked or disabled. If the messages differ, the login form becomes a way to find out which emails are registered.

A dummy hash so timing does not give the answer away. When the email does not exist, still run a comparison against a hash you computed once at startup:

// Computed once, so an unknown email still pays the hashing cost.
// Without it, response time reveals which emails have accounts.
let dummy: Promise<string> | null = null
const dummyHash = () => (dummy ??= bcrypt.hash('not-a-real-password', COST))

const account = await findAccountByEmail(email)
if (!account) {
  await bcrypt.compare(password, await dummyHash())
  throw new UnauthorizedError('Invalid email or password')
}

Without that, "no such user" returns in a millisecond and "wrong password" returns after a full hash. The two paths still do not do identical work (one has more database reads), so treat this as removing the big signal, not every signal.

Rate limits keyed two ways. Apply one limit per IP-and-email pair and another per IP:

await applyRateLimit(`signin:${ip}:${email}`, { max: 5, window: '15 minutes' })
await applyRateLimit(`signin-ip:${ip}`, { max: 30, window: '15 minutes' })

The numbers are examples, not recommendations; tune them to your traffic. The pair limit slows guessing at one account. The IP limit slows an attacker working through many accounts from one address. Neither stops a distributed attack from many IPs, which is why breach checks and MFA still matter.

A cost factor you can change. Make the work factor configuration, validated to a sane range with a floor at OWASP's minimum, rather than a constant buried in the code.

Upgrade hashes on login. Raising the cost factor only affects new hashes unless you migrate old ones. Because bcrypt embeds its cost in the hash, the upgrade is a few lines:

const ok = await bcrypt.compare(password, stored.passwordHash)
if (ok && bcrypt.getRounds(stored.passwordHash) < TARGET_ROUNDS) {
  const upgraded = await bcrypt.hash(password, TARGET_ROUNDS)
  await db.update(credentials).set({ passwordHash: upgraded }) // plus the usual WHERE
}

This is the only moment you have the plaintext, so it is the only chance to migrate a hash without forcing a reset.

What makes a good password policy (and what no longer does)

NIST SP 800-63B Revision 4 (dated 26 August 2025) changed several habits that many sign-up forms still carry. For passwords (it calls them memorized secrets):

  • Minimum length: 15 characters when the password is the only factor, and 8 when it is used as part of multi-factor authentication.
  • Verifiers should permit at least 64 characters.
  • No composition rules. Requiring a symbol, a digit, and an uppercase letter is explicitly not allowed.
  • No forced periodic changes. Change a credential when there is evidence it was compromised.
  • Check new passwords against a blocklist of commonly used, expected, or compromised values, including breach corpora.
  • Allow password managers and autofill, and allow pasting.
  • Limit consecutive failed attempts per authenticator per account to a maximum of 100. That figure is a ceiling; the tighter limits above are consistent with it.

A typical contract today looks like this: a minimum length, a generous maximum, and a policy function that rejects the account's own email, a short list of very common passwords, and anything found in a known breach. Check that the pieces agree with each other. A minimum of 12 is a common choice, but for a password-only system NIST's single-factor minimum is 15, so you either raise the minimum or add MFA. And the maximum must respect the algorithm: with bcrypt, a 128-character maximum has the 72-byte problem described earlier.

Checking for breached passwords without sending the password

The breach check uses the Have I Been Pwned Pwned Passwords range API, which is built so you never send the password:

  1. Compute the SHA-1 hash of the password.
  2. Send only the first five characters of the hash to https://api.pwnedpasswords.com/range/{prefix}.
  3. The API returns every hash suffix sharing that prefix, one per line as SUFFIX:COUNT. You compare the rest of your hash locally.

I verified the mechanics with the well-known example: SHA-1 of password is 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, so you request /range/5BAA6 and look for 1E4C9B93F3F0682250B6CF8331B7EE68FD8 in the response, which is present. According to the API documentation, it needs no key and has no rate limit. You can also send the Add-Padding: true header, which pads each response to a random 800 to 1,000 entries so that response size leaks less about the prefix; padded entries have a count of 0.

The tradeoff is what happens when the service is down. One option is to give the call a short timeout (a few seconds) and fail open: if the lookup errors, accept the password and log the failure, so a third-party outage cannot block every sign-up. The cost is that during an outage a breached password can get through. The other option is to fail closed and accept that sign-ups stop during an outage. NIST says verifiers must check; how to behave when the check is unavailable is your call, and it should be a deliberate one.

Resets and one-time codes are credentials too

A password reset is a second way to log in. If it is weaker than the password, attackers use it. A reasonable design for a reset or email-verification code looks like this:

PropertyA reasonable design
Code format8 digits
Lifetime10 minutes
Wrong guesses allowed5, then the code is dead
ResendRate limited per account and purpose
Stored asA hash over the code, the account id, the purpose and a server pepper
UseSingle use; issuing a new code invalidates earlier ones

These are example values, not a standard. The reasoning behind each is what to copy:

The guess budget makes a short code safe against online attack. With 8 digits there are 10^8 possible codes and 5 allowed attempts, so a single code can be guessed with a probability of 5 in 100,000,000, or 1 in 20 million. A 6-digit code with the same budget would be 1 in 200,000. The budget, not the digit count alone, carries the weight.

Unbiased digit generation. Taking a random byte modulo 10 makes some digits slightly more likely, because 256 is not a multiple of 10. Reject bytes at or above 250, the largest multiple of 10 below 256, so each digit is equally likely:

if (byte < 250) digits += String(byte % 10)

Bind the hash to its context. Include the account id and the purpose in what you hash, so a code issued for verifying an email cannot be replayed against a password reset, and one account's code is useless for another.

Make the attempt counter and the claim one statement. Increment attempts, enforce the limit, and mark the code consumed in a single conditional UPDATE, so two concurrent submissions cannot both succeed or dodge the counter. Read-then-write in application code leaves a race.

Be honest about what hashing the code buys. Eight digits is 100 million possibilities, which an attacker with a leaked table can enumerate instantly. The hash does not protect a short code from a database leak on its own. What protects it is the short lifetime and a server-side pepper that is not in the database. That is a reasonable design, but it is why the pepper does real work here and a salted-only hash would not be enough.

Uniform responses. The reset request should return the same message whether or not the email has an account. Cooldowns and delivery failures are logged server-side rather than shown, so the form cannot be used to enumerate accounts. (Sign-up is harder: it has to tell someone that an email is already registered, which is the classic place enumeration survives.)

Tie credential changes back to sessions

Part 2 covered revocation, and this is where it pays off. Handle the two cases differently:

  • Resetting a password should sign out every session for the account, and can treat completing the reset as proof of the email address.
  • Changing a password while signed in should sign out every other device and keep the current one.

The reasoning: a reset implies you may not control the account, so nothing should survive it. A change implies you are present on the device you are using, so keeping that session avoids a pointless re-login while still cutting off anything else that might hold access.

MFA: what it adds and which factor to choose

Multi-factor authentication is the control for the situation where the password is not secret anymore: it was phished, reused from a breached site, or guessed. OWASP's MFA cheat sheet cites an analysis by Microsoft suggesting MFA would have stopped 99.9% of account compromises. Treat that as a statement about one large population, not a guarantee for your app, but the direction is not in dispute: MFA is the best defense against credential stuffing and spraying.

The factors are not equal:

FactorStrengthMain weakness
SMS / voice codesWeakestNIST treats these as restricted; OWASP says not to use SMS for high-value or personal-data applications
TOTP (authenticator app)Good baselineA user can be tricked into typing the code into a phishing page
Push approvalConvenientPrompt bombing ("MFA fatigue"); require number matching
Passkeys / security keys (WebAuthn)StrongestNeeds recovery planning and a modern client

TOTP in detail

TOTP is defined in RFC 6238 and builds on HOTP (RFC 4226). The mechanics:

  • Client and server share a secret. Both compute an HMAC over the current time step and truncate the result to a short number. The RFC's default time step is 30 seconds.
  • HOTP specifies HMAC-SHA-1, and the TOTP RFC allows HMAC-SHA-256 or HMAC-SHA-512 instead. Test the algorithm and digit count against the authenticator apps your users actually have before changing defaults.
  • The RFC recommends allowing at most one time step of network delay, and says the verifier must not accept a second attempt of the same OTP after a successful validation. That means you store the last accepted time step per user and reject replays.
  • Keys should be random, and the RFC suggests a length equal to the HMAC output.

Two design consequences people often miss:

  1. The server must store the TOTP secret in recoverable form, because it has to compute codes. That is unlike a password hash. Encrypt the secret at rest and keep the encryption key outside the database.
  2. TOTP still needs the same attempt budget as a reset code. A 6-digit code with no throttling is a short search space.

Passkeys and WebAuthn

WebAuthn changes the model: instead of a shared secret, the user's authenticator holds a private key and your server stores only the public key. The credential is scoped to a Relying Party ID, and browsers and authenticators enforce that scoping, so a credential created for your domain cannot be used by a look-alike site. That is what makes it phishing-resistant, and why a code someone types into a fake page is a weaker design.

A passkey is the user-facing name for a discoverable credential: one the user can use without first typing a username, because the server can send an empty allow-list and let the authenticator pick. Credentials can also be flagged as eligible for backup across devices, which is part of how passkeys sync.

When a user signs in, your server verifies the assertion (WebAuthn Level 2, section 7.2). In plain terms the checks are:

  1. The client data type is webauthn.get.
  2. The challenge equals the one you issued for this attempt.
  3. The origin matches your site's origin.
  4. The rpIdHash is the SHA-256 of your expected RP ID.
  5. The User Present flag is set, and User Verified if you require it.
  6. The signature is valid over the authenticator data and client-data hash, using the stored public key.
  7. The signature counter is greater than the stored value when either is non-zero; if not, it may signal a cloned authenticator. Many authenticators keep the counter at zero, in which case this check does not apply.

Recovery is where MFA gets undone

Every strong second factor needs a way back in when it is lost, and attackers go for that path. OWASP's guidance: issue a set of single-use recovery codes at enrollment, let users enroll more than one factor, and require re-authentication with an enrolled factor (not just the active session) before factors are changed. Hashing recovery codes like passwords is my own recommendation rather than a quoted requirement, but it follows from treating them as credentials.

OWASP also lists where to require step-up verification: at login above all, and for sensitive actions such as changing the password or email address, disabling MFA, and elevating to an administrative session. For operators and administrators, require it outright. This pairs with Part 1's point that an admin account is a very large blast radius, and it sets up the next post on permission systems.

Linking back to OAuth

Part 3's warning applies here: if a user created an account with a password and later signs in with Google, linking them by email alone is only safe when the provider confirms the email is verified. Whichever method a user arrives by, make sure the strongest policy (MFA included) applies to the account, not to one entry point.

Practical debugging and checklist

See which algorithm and cost your table actually holds. The prefix tells you:

SELECT substr(password_hash, 1, 7) AS prefix, count(*) AS accounts
FROM customer_credentials
GROUP BY 1
ORDER BY accounts DESC;
-- $2b$12$ ... $2b$10$ ... a leftover older algorithm shows up here

Measure the hash on your real runtime, not your laptop. Wrap the call in performance.now() locally, then log the duration in a deployed environment. Treat the deployed number as the one that counts.

Look for the 72-byte problem before it bites. If you use bcrypt, check what bcrypt.truncates(password) returns for your longest allowed input.

Check the breach lookup by hand, so you know the integration is right:

# SHA-1 of the password, uppercase
printf '%s' 'password' | sha1sum | tr 'a-f' 'A-F' | cut -c1-40
# 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8

# Request only the 5-character prefix, then look for the rest locally
curl -s https://api.pwnedpasswords.com/range/5BAA6 | grep 1E4C9B93F3F0682250B6CF8331B7EE68FD8

Probe for account enumeration. Compare an existing and a nonexistent email, several times each, for both response text and timing:

for email in exists@example.com nobody@example.com; do
  curl -s -o /dev/null -w "$email %{time_total}s\n" \
    -X POST https://staging.example.com/api/sign-in \
    -H 'content-type: application/json' \
    -d "{\"email\":\"$email\",\"password\":\"wrong-password-123\"}"
done

Run this against staging, not production, and pace it under your rate limit. A single sample proves nothing; you are looking for a consistent gap.

Production checklist:

  • Password hashed with Argon2id (or scrypt, or bcrypt at cost 10+), with parameters stored in the hash.
  • Maximum length consistent with the algorithm (72 bytes for bcrypt).
  • Blocklist and breach check at sign-up, reset and change; a deliberate fail-open or fail-closed decision.
  • No composition rules, no forced rotation, paste allowed.
  • Rate limits per account and per IP, a uniform error message, equalized timing for unknown emails.
  • Resets and one-time codes: short expiry, attempt budget, single use, uniform response, bound to purpose.
  • All sessions revoked on reset, other sessions revoked on change.
  • Cost factor can be raised, and hashes are upgraded on login.
  • MFA available, ideally with a phishing-resistant option; recovery codes; step-up for sensitive actions.

Interview answer vs production answer

The interview answer: "Hash passwords with a slow algorithm like bcrypt or Argon2, with a unique salt, never store plaintext." That is right and gets you through a screen.

The production answer adds what the interview answer skips:

  • The algorithm is not the whole risk. Most takeovers come through credential stuffing, weak resets, or phishing. A perfect hash does nothing for those.
  • A hash is only as good as its limits. bcrypt's 72-byte cap, the cost you can afford on your runtime, and whether old hashes ever get upgraded all matter more than the algorithm's name on a slide.
  • Policy follows current evidence. NIST's 2025 revision asks for length and blocklists, not composition rules and rotation.
  • Every recovery path is a login path. Reset codes, email changes and MFA recovery need the same rigor as the password.
  • Failure behavior is part of the design. What your system does when the breach API, the mailer or the rate limiter is unavailable is a security decision, so make it on purpose.

FAQ

Should I use Argon2id or bcrypt?

Argon2id if your runtime supports it well; OWASP lists it first and bcrypt only for legacy systems. bcrypt is still sound at a suitable cost when it is what your platform offers, as long as you handle the 72-byte limit.

Do I need to hash a password on the client before sending it?

No. The password arrives over TLS and is hashed on the server. Hashing on the client turns the hash itself into the credential, which does not help, and it moves a security-sensitive step into code you do not control.

What cost factor should I pick?

Start from OWASP's minimums, then raise the cost until a single hash takes as long as your login path can tolerate on production hardware. Re-evaluate as hardware and traffic change, and store parameters in the hash so you can.

Is a pepper worth adding?

It is defense in depth: it protects you if only the database leaks. It adds operational cost, because rotating a compromised pepper forces resets. If you add one, keep it in a secrets store, not next to the data.

Why not force users to change passwords every 90 days?

NIST no longer asks for it. Forced rotation tends to produce predictable variants of the old password. Change credentials when there is evidence of compromise.

Is SMS two-factor authentication acceptable?

It is better than no second factor, but it is the weakest option. NIST treats it as restricted, and OWASP recommends authenticator apps or phishing-resistant factors instead, and advises against SMS for high-value or personal-data applications.

Are passkeys a replacement for passwords?

They can be. A passkey removes the shared secret, so there is nothing to phish or leak from your database. Plan for lost devices, recovery, and users on older clients, and keep a fallback path as strong as the passkey.

Why does the same error message matter?

Different messages (or noticeably different response times) for "no such account" and "wrong password" let an attacker build a list of valid emails, which makes credential stuffing and phishing cheaper.

Glossary

TermSimple meaning
HashA one-way function: easy to compute, infeasible to reverse
SaltA unique random value added per password, stored beside the hash
PepperA secret value shared across hashes and kept outside the database
Work factorThe tunable cost (iterations, memory) that makes each guess expensive
Argon2idA memory-hard password hashing function; OWASP's first choice
bcryptAn older adaptive hash; reads at most 72 bytes of input
Credential stuffingTrying leaked email and password pairs against another site
k-anonymity range lookupSending only a hash prefix so the full password never leaves your server
OTPA one-time code, valid once and for a short time
TOTPA time-based OTP derived from a shared secret and the current time step
WebAuthnThe browser standard for public-key credentials scoped to a site
PasskeyA discoverable WebAuthn credential, often synced across devices
Step-up authenticationAsking for an extra factor before a sensitive action
MFA fatigueFlooding a user with push prompts until one is approved

The end-to-end mental model

           SET A PASSWORD                              PROVE IT AGAIN
           --------------                              --------------
  user -> length, blocklist, breach check      user -> rate limit (IP+email, IP)
              |                                           |
              v                                           v
     slow salted hash  --(stored)-->  [ DB ]  <--  load hash or dummy
     (parameters travel with it)                          |
                                                          v
                                                 verify at equal cost
                                                          |
                              +---------------------------+---------------+
                              | wrong / unknown / locked                  | correct
                              v                                           v
                    one generic error                         second factor if enrolled
                                                              (passkey > TOTP > SMS)
                                                                          |
                                                                          v
                                                    new session (Part 2); revoke others
                                                    on password change or reset

  AROUND IT: reset codes (expire, budget, single use) · recovery codes · step-up for sensitive actions

Part 3 ended by saying OAuth is the front door that decides who gets in. This post covered the case where you build the door yourself: the lock (a slow hash), the guard (limits and uniform errors), the spare keys (resets and recovery), and the second lock (MFA). All of it answers one question: who is this person?

What it does not answer is what that person is allowed to do once the door opens. That is the next step in the series: building a permission system with RBAC and ABAC, using CASL.

About the author

Suriyaprakash Somu is a full-stack developer based in Erode, Tamil Nadu, building React and Node.js applications on relational databases. He works on customer-facing experiences, reusable interfaces and access-controlled backend workflows. Explore his development work or discuss a project or developer role.