
Password Hashing: Why Login Should be Deliberately Slow
What is password hashing?
In a previous post, I discussed that authentication is the process of verifying a user's identity. The most basic version of this is a user providing an email and a password when first signing up on the platform, which are stored together in the system's database. In succeeding login attempts, the credentials you provide are matched with those stored in the database. If there's a match, you're logged in.
The most crucial component in this flow is your database as it's your source of truth. What if an attacker compromises your system and is able to steal your database data? Now, all of your user credentials are leaked! They can get access to any user's account using this information and could also use this to gain access to other systems where the user reuses the same credentials.
In an ideal world, we'd make the database impenetrable so that no attacker can gain access to it. However, practicality requires us to prepare for the worst-case scenario, so we need to find a way to prevent attackers from seeing user credentials even if the database is leaked. This is where password hashing comes in.
Password hashing refers to the process of converting a user's password into a fixed-length string made of seemingly random characters, which we call a hash. The function that accomplishes this process has the very unique property of making it hard to derive the input string given the output hash.
How does this solve our problem from earlier? Instead of storing the password directly in the database, we can hash the password and store the hash in the database instead. The consequence of this is that even if an attacker manages to gain access to the database, they aren't able to see the user's actual password since it's hashed.
This slightly alters our authentication flow. For registration, instead of storing the password directly, we hash it and store the hash. For login, instead of performing a direct lookup with the plaintext email and password, we hash the password and perform the lookup with the email and hashed password.
Now that we know what password hashing is and why we need it, let's look into the details of how we implement it properly.
As you first read about what password hashing is, you may find yourself asking "Why don't we just use encryption?" This was a question I found myself asking as well while studying the topic.
Encryption is the process of scrambling text data into an unreadable format via an algorithm and some kind of digital key. The purpose of this digital key is to either scramble the plaintext data into an encrypted text or unscramble the encrypted text into the original plaintext data. This means that encryption is a reversible process, which is not good for securing passwords!
The key represents a single point of failure. This means that if an attacker can get access to this key along with the database data, even if it is encrypted, the key allows the attacker to recover the original plaintext passwords. Hashing doesn't have this problem as it's an irreversible process.
How do we implement password hashing?
What are salts?
Aside from being mathematically irreversible, one of the other key properties of a hashing function is determinism. Given the same input string, the same output hash will be computed. This means that if two users use the same password (e.g. "password", which is way more common than you think), then their hashes will be the same. Is this problematic?
Yes! If an attacker is able to learn the plaintext password of a particular user, all other users with the same password hash are also compromised. An attacker can build a pre-computed table that contains a mapping of common passwords to their hashes and then use this mapping to perform a quick lookup of your system's database to extract the credentials of users with those passwords. This mapping is called a rainbow table. How do we address this?
We use salts. It's a random piece of data that's generated per user and mixed with the plaintext password for hash computation. Consequently, "password" with salt A produces a completely different hash than "password" with salt B. This means that every user requires an independent attack to crack. If user A's password has been cracked, user B's password won't be even if it's the same since salt B changes the hash to be different from that of user A's.
An attacker with your database data still has the salt, as it's stored alongside the hash, so they can still attempt to guess passwords one at a time.
Salts prevent pre-computation, or doing the work once and applying it to multiple users since users with the same password no longer have the same hash because they have their own salts.
What kind of hash functions should we use?
You may be tempted to reach for a general-purpose hashing algorithm like SHA-256. However, algorithms like SHA-256 are designed to be fast, which is the opposite of what we want for password hashing. If we used an algorithm that's fast, it becomes easier for an attacker to perform brute-force attacks.
Password hashing functions are deliberately slow by design. bcrypt with a cost factor of 12 takes roughly 250 milliseconds to compute a hash on modern hardware, which is thousands of times slower than the time it takes to compute SHA-256 hashes. The cost factor in bcrypt refers to an exponent that leads to increasing/decreasing the time it takes to compute a hash where incrementing it by 1 doubles the time it takes to compute a hash.
1const saltRounds = 12;2const myPlaintextPassword = "password";34// Flow 1: Separate salt generation and hashing5const salt = bcrypt.genSaltSync(saltRounds);6const hash = bcrypt.hashSync(myPlaintextPassword, salt);7// Store hash in your password DB.89// Flow 2: Generate salt and hash together10const hash = bcrypt.hashSync(myPlaintextPassword, saltRounds);11// Store hash in your password DB.
How do we compare hashes?
Since hashing is a one-way process, when a user provides their password in an attempt to log in, we need to hash it and compare it with the one that's stored in the database.
However, there's a nuance to how this must be implemented. In Typescript, a naive comparison would look like this:
1// Naive comparison2const {email, passwordHash, salt} = fetchUserInformation()3const attemptedPassword = "password"4const attemptedPasswordHash = bcrypt.hashSync(attemptedPassword, salt)56if (attemptedPasswordHash !== passwordHash) {7 throw new Error("Unauthorized")8}910// Continue login...
Although this code is able to compare the two hashes, the comparison operation is vulnerable to what's called a timing attack. The strict equality operation of !== compares the hashes a character at a time and exits when there's a mismatch. How is this problematic? For example, say that the actual hash is a9cve3:
- Input hash is
a9cee3, comparison takes 2.1 milliseconds. - Input hash is
a9cvf3, comparison takes 2.3 milliseconds. - Input hash is
a9cve2, comparison takes 2.5 milliseconds.
To put it concisely, the time it takes to do the comparison operation between the input hash and the actual hash depends on how closely they match. An attacker can use this information to slowly leak the user password by noting the differences in the duration of the comparison operation across attempts.
To prevent this, we use timing-safe comparison operations. bcrypt already provides this out of the box with its .compare() method
1// Timing-safe comparison2const {email, passwordHash, salt} = fetchUserInformation()3const attemptedPassword = "password"4const attemptedPasswordHash = bcrypt.hashSync(attemptedPassword, salt)56// Load hash from your password DB.7bcrypt.compare(myPlaintextPassword, hash).then(function(result) {8 if (!result) {9 throw new Error("Unauthorized")10 }11 // Continue login...12});
Under the hood, the method always examines every byte regardless of where the mismatch occurs.
What password hashing libraries can we use?
As of writing this post, OWASP has an article which recommends the following libraries:
Rank | Algorithm | When to Use |
|---|---|---|
1 | Argon2id | New applications |
2 | scrypt | When argon2id isn't available |
3 | bcrypt | Legacy systems or systems with constrained resources |
4 | PBKDF2 | FIPS-140 compliance requirements |
bcrypt has been the standard for a long period of time, but it has a couple of limitations, namely:
- Maximum length for inputs is limited to 72 bytes (or 72 characters for standard ASCII), so passwords that are longer than this are truncated.
- Only allows control over the cost factor, which just targets CPU time.
Why is the second limitation of concern? It's because attackers can perform brute-force attempts in parallel. bcrypt's cost parameter only controls how long it takes to compute a hash, but it doesn't control how many computations can be done in parallel. An attacker with a strong GPU can run thousands of these computations in parallel, which partially makes up for the slowness.
It is because of this limitation that Argon2id wins.
If you're familiar with the Better Auth library, they actually use scrypt under the hood by default. However, they do give you the option to swap it with a different implementation. To learn more, you can check out their configurations page.
Why is Argon2id recommended?
While bcrypt only allows you to control one parameter, Argon2id offers three:
- Memory - Amount of RAM it takes to compute each hash
- Time - Similar to the cost parameter of bcrypt, controlling how long each hash takes to compute
- Parallelism - The number of threads our server uses to compute each hash
The parameter here that thwarts GPU parallelization attacks is memory. Although GPU's can have a large number of cores, with each core trying to compute a hash, the memory per core is limited. What we can do is tune the memory parameter such that the memory required to compute a hash is more memory than what's allocated to a single core. As a result, it would be impossible to have any single core compute a hash, lowering the number of parallel attempts an attacker can perform.
How to configure Argon2id?
OWASP also provides a recommended configuration when setting up Argon2id, splitting the configuration into two tiers depending on the available memory in your server:
Tier | Memory (m) | Iterations/Time (t) | Parallelism | When to use |
|---|---|---|---|---|
First choice | 46 MiB | 1 | 1 | Default |
Fallback | 19 MiB | 2 | 1 | Memory-constrained environments |
Sounds like a lot! However, the code is actually pretty straightforward:
1import argon2 from "argon2";23// First choice — 46 MiB4const hash = await argon2.hash(password, {5 type: argon2.argon2id,6 memoryCost: 47104, // 46 MiB in KiB7 timeCost: 1,8 parallelism: 1,9});1011// Verification12const isValid = await argon2.verify(hash, inputPassword);
In just a few lines of code, we've made our system more resistant to parallelization attacks. Now, there's one more implementation detail we need to consider: how to deal with invalid login attempts. The bottom line is that they should be communicated to the user vaguely.
Why should authentication error messages be vague?
Consider we have the following login responses:
Credentials | API Response |
|---|---|
Email: [email protected] Password: hello123 | User not found |
Email: [email protected] Password: hello 234 | Wrong password |
This may appear to be good practice as we're making it clear to the user which credential they input was wrong. Alice may have mistyped her email while Bob mistyped his password. However, to an attacker, if they input those same credentials for Bob, the API response just gave away that Bob has an account! They can then run a targeted brute-force attack or credential-stuffing attack against Bob's email to try to figure out his password.
This is a very real vulnerability that OWASP calls username enumeration. The fix comes in to parts. First, we return the same message for both cases. This is what it looks like:
1const user = await db.findUserByEmail(email);2if (!user) throw new Error("Invalid email or password");34const isMatch = await bcrypt.compare(password, user.hashedPassword);5if (!isMatch) throw new Error("Invalid email or password");
By returning the same message, no information leaks. An attacker can no longer distinguish between an account not existing from a wrong password.
The second fix is a bit more subtle and it has to do with timing. In the code above, notice that if the user account doesn't exist, the error is returned right away. However, if the password is wrong, the error is returned after the hash comparison. This comparison makes the operation take slightly longer than if the comparison didn't happen. However, an attacker could infer if the account is valid based on this timing difference.
To resolve this, we run a dummy hash comparison on the "user not found" path so both branches take the same time.
1const user = await db.findUserByEmail(email);2const isMatch = await bcrypt.compare(password, user.hashedPassword);34if (!user) throw new Error("Invalid email or password");5if (!isMatch) throw new Error("Invalid email or password");
What does the full picture look like?
We covered a lot of ground! Here's a quick summary of the password hashing flow:
Every piece serves a specific purpose to harden our system:
- One-way hashing prevents mass plaintext password leaks even after a full database breach.
- Salts force targeted attacks, defeating pre-computation.
- Work factor makes each guess take a certain amount of time, capping brute-force attempts.
- Memory-hardness caps GPU parallelism by requiring a large amount of RAM per attempt.
- Timing-safe comparisons prevent leakage of hash characters.
- Consistent error messages prevent username enumeration.
Hope you learned something. Happy coding!
Related Posts

Authentication is a feature that's present in virtually every system you touch. In this article, I'll discuss h