AuthnGENERALRUNTIME-SPECIFICLANGUAGE-SPECIFIC

Credentials and Password Handling

Store passwords with a slow, salted, purpose-built hash from a maintained library. Everything else in this lesson is a consequence of that sentence.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

What does a backend have to do with a password, from the moment it arrives to the moment it is verified?

The requirement

Users sign up with an email address and a password and expect to log in again later. Nobody asked for this to be interesting.

The obvious build

Hash the password with a fast general-purpose hash before storing it, compare hashes on login, and move on. It is one line, it is not plaintext, and it looks like the responsible choice.

Why it breaks

A fast general-purpose hash is designed to be fast, which is precisely the wrong property here: hardware that computes it billions of times per second turns a stolen table into a list of plaintext passwords for every guessable entry.

How it breaks in production
  • A fast general-purpose hash is designed to be fast, which is precisely the wrong property here: hardware that computes it billions of times per second turns a stolen table into a list of plaintext passwords for every guessable entry.
  • Without a per-password salt, identical passwords produce identical hashes, so one crack covers every user who chose it, and precomputed tables apply directly.
  • The login endpoint returns "unknown email" and "wrong password" as different errors, which is a free account-enumeration oracle.
  • Verification short-circuits when the user does not exist, so the response is measurably faster for unknown accounts — the same oracle, through timing instead of text.
  • There is no rate limit on login, so an attacker with a credential-stuffing list gets unlimited attempts against every account you have.
  • The password reset token is generated with a general-purpose random function rather than a cryptographic one, and becomes a second, weaker way into every account.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • A password is a low-entropy secret chosen by a human. Every defence follows from that. Humans reuse passwords, pick guessable ones, and appear on breach lists. The storage scheme has to survive an attacker who has your entire table.
  • Password hashing is deliberately slow and deliberately memory-hard. bcrypt, scrypt and Argon2 exist to make each guess cost real time and, for scrypt and Argon2, real memory — because memory is the resource that specialised cracking hardware has least of.
  • A salt is a unique random value per password, stored alongside the hash. It makes precomputation useless and makes two identical passwords hash differently. Modern password-hash formats encode the salt and the parameters in the stored string, so verification knows how to reproduce the hash.
  • Cost parameters are tuned, not memorised. The target is the slowest setting your login path can afford at your peak login rate on your hardware — a real budget, measured, and revisited when hardware changes. Numbers published in an article are a snapshot of someone else's hardware and someone else's traffic.
  • Verification is a comparison of derived values in constant time. Your library does this; a naive == on the derived bytes is a timing side channel.
  • Hashing is not encryption. There is no key and no way back, which is the point — you must never be able to recover a user's password (Hashing vs Encryption vs Encoding in Security Engineering).

Fast hash versus password hash

GENERALArgon2 is shown because its API makes the parameter-upgrade path visible. bcrypt and scrypt through a maintained library are equally acceptable choices; the properties in the "because" line are what matter, not the name.

The two snippets below differ by one library call and by the entire security of your user table. The left one is not a strawman: it is what "we hash our passwords" usually means when nobody has looked.

The right one is short because the library owns the salt, the parameter encoding and the constant-time comparison. That is the argument for using it: every part it hides is a part that has been got wrong in production somewhere.

Storing and verifying a password
A general-purpose hash
const hash = sha256(password + user.email)
// stored as a hex string

// verify
if (sha256(input + user.email) === user.hash) { /* ok */ }

// designed for speed, so guessing is fast;
// email as salt is not secret and not random;
// === on the digest compares byte by byte
A purpose-built password hash
import argon2 from 'argon2'

// signup — salt and parameters are generated and
// encoded inside the returned string
const stored = await argon2.hash(password)

// login
const ok = await argon2.verify(stored, input)  // constant-time
if (ok && argon2.needsRehash(stored, currentParams)) {
  await users.updateHash(user.id, await argon2.hash(password, currentParams))
}

The algorithm on the right is slow and memory-hard on purpose, salts each password uniquely, records the parameters used so they can be raised later, and compares in constant time. The one on the left is optimised for throughput, which is the attacker's goal, not yours.

What tuning actually means

The most common question about password hashing is "what cost factor should I use", and the honest answer is that the question has no portable answer. The parameter buys attacker time and spends your CPU, and both sides of that trade move with hardware.

What does transfer is the procedure. Set a login latency budget, measure the real cost on your real instance type at your real peak login concurrency, take the highest parameters that fit, write down the date and the hardware, and put a reminder in the calendar to redo it. Then make sure your code can raise the parameters later without locking anyone out — which is what rehash-on-login is for.

  • Budget first — decide what login latency you will accept, and at what peak concurrent login rate. That is the constraint.
  • Measure on your hardware — the same parameters cost differently on different instance types, and a published number is someone else's machine.
  • Take the highest that fits — within the budget, more cost is strictly better against offline cracking.
  • Record parameters with the hash — the encoded hash string does this for you; that is why you store the whole string.
  • Upgrade lazily — rehash on the next successful login, because that is the only moment you hold the plaintext.
  • Bound the exposure — hashing is expensive by design, so rate-limit the endpoints that trigger it or an attacker sets your CPU bill (Rate Limiting).
  • Re-measure on a schedule — the right parameter drifts upward with hardware; a value that is never revisited is a value that decays.

The rest of the credential surface

Password storage is the part everybody knows to think about. The rows below are the parts that get skipped, and each of them has been the actual path in real incidents — the hash was fine and the account was taken over anyway.

TriggerSymptomCauseResponse
Distinct errors for unknown email and wrong passwordAn attacker enumerates which addresses have accountsThe response distinguishes two failures that should look identicalOne message, one status code, on login, reset and signup alike
Verification skipped for unknown accountsUnknown emails answer measurably fasterA timing oracle from an early returnHash against a dummy value so both paths do the same work
No rate limit on loginCredential-stuffing succeeds on reused passwordsHashing protects a stolen table, not live guessingPer-IP and per-account limits, progressive delay, and MFA on risk signals
Reset token stored in plaintextA read-only database leak becomes account takeoverThe token is a credential and was not treated as oneRandom, single-use, short-lived, stored hashed, invalidated on use
Password change leaves sessions aliveThe attacker stays logged in after the user reactsCredential change and session lifetime were unrelatedInvalidate every session and refresh token on password change (Where Sessions Live)
Request bodies logged wholesalePlaintext passwords in the log storeA generic logging middleware with no redactionField-level redaction by default, and a test that asserts it (Secrets in Logs)

How to build it

Most important first.

  • Use a maintained password-hashing library that implements bcrypt, scrypt or Argon2, with its parameters exposed. Do not implement the construction yourself, and do not build a scheme out of general-purpose hash primitives.
  • Tune the cost parameters against a measured budget: pick the highest cost whose verification time your login endpoint and your peak concurrent-login rate can absorb, measure it on the hardware you actually run on, and re-measure when you change instance types.
  • Store the full encoded hash string — algorithm, parameters and salt included — so you can raise parameters later without losing the ability to verify old hashes.
  • Rehash on successful login when the stored parameters are below current policy. You have the plaintext exactly once per login; that is the only moment an upgrade is possible.
  • Run the hash verification even when the account does not exist, against a dummy hash, so the response time and the response body are the same either way.
  • Return one message for every login failure, and use the same status code for all of them.
  • Rate-limit by IP and by account, with progressive delay or lockout, and count failures as a security signal (Rate Limiting, Rate Limiting as a Security Control in Security Engineering).
  • Treat password reset tokens, email verification tokens and MFA recovery codes as credentials in their own right: cryptographically random, single-use, short-lived, stored hashed, and invalidated on use.
  • Support long passwords and passphrases, and check new passwords against a known-breached list rather than imposing composition rules that push users toward Password1!.
  • Where you can, prefer a scheme that removes the password from the threat model entirely — federated login or passkeys (Passkeys and WebAuthn in Security Engineering).

What can go wrong

Failure modes
  • Cost parameters set once at launch and never revisited, so a value chosen years ago is now cheap for an attacker and still slow for your users.
  • Cost set so high that the login endpoint becomes a denial-of-service surface: hashing is CPU-heavy by design, and an unauthenticated attacker choosing how often you do it is a capacity problem (Resource Limits).
  • On a single-threaded runtime, a synchronous hash call blocks every other in-flight request on the instance for its whole duration (Blocking the Event Loop).
  • Truncation surprises: bcrypt operates on a bounded number of bytes, so very long passwords are silently cut. Pre-hashing to work around it must be done exactly as your library documents, or it introduces its own weakness.
  • The password reaching a log, an error report or an APM payload because the request body was logged wholesale (Secrets in Logs).
  • Account lockout implemented without thought becomes a denial-of-service tool against named users.
  • A password change that does not invalidate existing sessions and tokens, so the compromise the user is responding to survives their response (Session Authentication, Token Authentication and the Revocation Problem).
What can race
  • Concurrent failed logins can race the counter that drives lockout, so a burst of parallel attempts gets more tries than the policy allows unless the counter is incremented atomically (Atomic Operations).
  • Two logins for the same account arriving together can both decide to rehash. Both writes are valid hashes of the same password, so last-writer-wins is harmless — which is worth knowing so nobody adds a lock that is not needed.
Security
  • This is safety-critical code. Use a maintained library, keep it updated, and never hand-roll the construction — the failure mode is silent and the blast radius is every user.
  • Never a fast general-purpose hash for passwords, with or without a salt. Speed is the vulnerability.
  • Never plaintext, never reversible encryption, and never a scheme that lets support staff read a password.
  • Uniform failures everywhere the existence of an account could be inferred: login, password reset, signup and email change all leak enumeration if they answer differently (How Passwords Are Actually Attacked in Security Engineering).
  • Hash password reset tokens in storage too. A stolen database of reset tokens is a stolen set of account takeovers.
  • Consider an application-side pepper — a secret mixed in from configuration rather than from the row — knowing that it protects against a database-only compromise and not against a full application compromise, and that rotating it is a migration (Secrets Are Not Configuration).
  • Rate limiting and MFA are what stop online guessing; hashing only decides what a stolen table is worth (Multi-Factor Authentication in Security Engineering).
Misreads
  • "We hash passwords, so we are fine." Which hash decides everything. A fast general-purpose hash of a salted password is still cracked at hardware speed.
  • "Salting is the important part." Salting defeats precomputation. Slowness defeats brute force. You need both, and only one of them is a property of the algorithm.
  • "Use cost factor N." N is a property of hardware and traffic at a moment in time. Measure against your own login budget and re-measure — a number copied from a blog post is out of date the year after it was written.
  • "MD5 is fine if we salt it." No. The salt does not slow anything down.
  • "Complexity rules make passwords strong." They mostly make them predictable. Length, a breach-list check and MFA do more.
  • "We can decrypt it for support." If support can read it, so can an attacker who reaches support's access path.

Operating it

How you see it in production
  • Count login outcomes by reason internally — even though the response is uniform — so credential stuffing is visible as a shape rather than a support ticket.
  • Failed logins per account and per IP, alerted on the distribution: many accounts one failure each is stuffing; one account many failures is targeted.
  • A latency histogram of the hash verification step, which is how you notice that a parameter change has eaten your login budget.
  • Count logins that triggered a rehash, so you can tell when a parameter migration is finished.
  • Password reset requests per account per hour — an abuse channel that is rarely watched.
What changes at 10x and 100x
  • Login is CPU-bound by construction, so login capacity is a CPU planning question, not a database one. A burst of logins after an outage or a marketing send is a real load event.
  • At 10x users, nothing about the storage changes. At 10x login rate, the cost parameter and the instance size are directly coupled — and lowering the parameter to cope is weakening security to buy capacity, which is a decision to make explicitly.
  • A parameter migration across a large user table happens lazily on login and can take as long as your least active users take to return, so both old and new parameters must remain verifiable indefinitely.
What this costs
  • Higher cost parameters mean better resistance to offline cracking and more CPU per login and a larger denial-of-service surface. The tuning target is a budget, not a maximum.
  • A pepper adds a layer against database-only theft and adds a secret whose rotation requires rehashing every password.
  • Uniform error messages protect against enumeration and make life harder for legitimate users who mistyped their email; the usual answer is a clear self-service reset flow rather than a more specific error.
  • Passkeys and federated login remove password storage from your system and add a dependency, an account-recovery design problem, and a device-loss story.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALSlow, salted, purpose-built, from a maintained library, applies to every language, framework and scale. This is one of the few places in this domain where a near-universal statement is correct.
  • RUNTIME-SPECIFICWhere the CPU cost lands differs: in Node a synchronous hash blocks the single loop thread and must be the async variant or moved to a worker; in CPython the GIL is released by the C implementation so other threads proceed; in Go and the JVM it is ordinary work on a pool thread. Same algorithm, three different effects on unrelated requests.
  • LANGUAGE-SPECIFICLibrary ergonomics differ in ways that matter: PHP's password_hash/password_needs_rehash and Django's hashers make parameter upgrades a first-class feature, while a bare bcrypt binding in Node or Go leaves the rehash-on-login logic for you to write. The algorithm is the same; the chance of forgetting the upgrade path is not.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.