Why the login query needs a placeholder
Using db.all("... WHERE email = ?", [email]) instead of gluing email into the SQL string matters because gluing strings lets attacker-controlled input become SQL code, which is SQL injection.
A login form is the single most attacked input on the internet, and a glued query such as WHERE email = '' OR '1'='1' can log an attacker in as anyone. The query returns a row, the code sees a user, and nothing about the request looks unusual in a log.
Placeholders keep values as data and never code, because the driver sends the query structure and the values separately. That separation is exact rather than a filter, which is why it cannot be worked around by a cleverer input.
This unit is about the rest of the login story, meaning what to store and how to prove who is asking on later requests.
Rule zero: never store the password
If your users table contains real passwords and the database ever leaks (it happens to giant companies), every account is instantly lost, including accounts on other sites where users reused the password.
So servers store a hash instead: the output of a one-way function. A hash function like SHA-256:
- always gives the same output for the same input,
- changes completely when the input changes by one character,
- cannot be run backward: the hash does not reveal the password.
Login then works without ever storing the secret: hash what the user typed, compare hashes. In the output below, the long hex string is what a database would hold.
Hashing with Node's crypto module
The built-in crypto module computes SHA-256, giving the same hash for the same input and a completely different hash for a one-character change.
const crypto = require("node:crypto"); function sha256(text) { return crypto.createHash("sha256").update(text).digest("hex"); } console.log(sha256("hunter2")); console.log(sha256("hunter2") === sha256("hunter2")); console.log(sha256("hunter3") === sha256("hunter2"));
Output
f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7 true false
digest("hex") formats the raw hash bytes as the familiar hex string, which is what makes the value storable in a text column and printable in a log.
The true line is what makes login possible at all, since the same password always produces the same hash and can therefore be compared. The false line is what makes the hash safe, since hunter3 shares nothing visible with hunter2.
That second property has a name, the avalanche effect, and it is why a hash cannot be attacked by getting close. There is no sense in which one hash is nearly right, so guessing has to be exact.
The output length is fixed at 64 hex characters regardless of the input, so hashing a one-character password and a whole book both give the same size. That is why a password_hash column has a known width and reveals nothing about the password's length.
Note that this function is deliberately not what you should use for passwords, and the next block explains why. It is the right tool for checksums and content fingerprints, where speed is a feature rather than a liability.
Salt, and why bcrypt exists
Plain SHA-256 has two problems as a password hash:
- Attackers precomputed hashes for millions of common passwords (rainbow tables). If your table stores
sha256("hunter2"), a lookup cracks it instantly. The fix is a salt: a random string stored next to each user, mixed into the hash. Nowsha256(salt + password)differs per user, and precomputed tables are useless. - SHA-256 is fast, so attackers can guess billions per second. Real password hashers (bcrypt, argon2) are deliberately slow and salted for you:
const bcrypt = require("bcrypt"); const hash = await bcrypt.hash(password, 12); // store this const ok = await bcrypt.compare(candidate, hash); // login check
In production: bcrypt or argon2, full stop. The exercise below uses sha256(salt + password) only so you can see the salted-verify mechanic with your own eyes.
Verifying a salted hash
checkPassword(user, candidate) hashes user.salt + candidate and compares the result to the stored hash, which was made from the salt plus "hunter2".
const crypto = require("node:crypto"); function sha256(text) { return crypto.createHash("sha256").update(text).digest("hex"); } // what the users table stores: never the password itself const user = { name: "ada", salt: "c3f1&", passwordHash: "cb4817330f7de397ec0381a6af7ec252cb259ee408031b74f66d758b1a8549a3", }; function checkPassword(user, candidate) { return sha256(user.salt + candidate) === user.passwordHash; } console.log(checkPassword(user, "hunter2")); console.log(checkPassword(user, "hunter3"));
Output
true false
The whole check is one line, return sha256(user.salt + candidate) === user.passwordHash;, and the salt goes first, matching how the stored hash was created. Reversing the order gives a different hash and a login that always fails.
Notice that the correct password is never stored anywhere in this code. The user object holds a salt and a hash, and the only place the real password exists is the candidate argument, which lives for the duration of the call.
The salt being stored in plain text next to the hash surprises people and is correct. A salt is not a secret, and its job is to make every user's hash unique so one precomputed table cannot crack the whole database at once.
This demonstrates the mechanic, and production code uses bcrypt.compare instead. That function also handles the timing question, since comparing hashes with === can leak information through how long the comparison takes, which is why real libraries use a constant-time comparison.
Why bcrypt hashes survive a leak better
Because bcrypt is deliberately slow and salted per user, so guessing runs at thousands of tries per second instead of billions, and precomputed tables are useless.
Against hashes, the attacker's only move is guessing candidate passwords and hashing each one. Nothing is decryptable, since hashes are one-way, so the entire attack is a race between their hardware and the cost of one hash.
bcrypt's built-in cost factor, the 12 in bcrypt.hash(password, 12), makes every single guess expensive. Each increment of that number doubles the work, which is what lets the cost be raised over the years as hardware improves.
The per-user salt forces that whole effort to be repeated for every user. Cracking a thousand accounts costs a thousand times cracking one, where an unsalted table would let a single pass through a wordlist crack every account that shared a password.
| Stored as | Attacker's cost |
|---|---|
| plain text | zero, it is already done |
| unsalted SHA-256 | one wordlist pass for the whole table |
| salted SHA-256 | one wordlist pass per user, still fast |
| bcrypt | one slow wordlist pass per user |
Weak passwords still fall eventually, and that is the honest limit of hashing. A password of 123456 is in the first hundred guesses no matter how slow the hash is, which is why rate limiting and minimum-length rules exist on top rather than instead.