Password Hashing in Javascript
Storing password data 💾
In the 2010s, several high profile data breaches were able to obtain user passwords because they were stored using weak hashes, weak encryption or, embarrassingly, plain text. These breaches included well known organizations like LinkedIn, Adobe, Yahoo, MySpace and Equifax. It brought to light how even multi-million dollar corporations, with the means of mitigating the effects of data breaches, had chosen to not prioritize adopting established password storing techniques. Properly storing passwords not only protects user data, but also protects applications against bad actors gaining access to privileged administrator accounts.
To protect sensitive data like a password, software engineers use a technique called hashing before storing it in a database. Hashing a password means that an application takes the plain text entered into a password field and uses an algorithm to convert it into a unique, irreversible jumble of data. When a user reenters their password to log in, the application never looks up the original text of the password. Instead it sends the reentered password into the same hashing algorithm, which will produce the same hash that was stored in the database. The application can confirm the password is correct when the stored hash matches the newly computed hash. You can experiment with how to make two identical hashes below:
Hash 1:
Hash 2:
Your hashes match 🥳
With the native WebCrypto API in Javascript, we can create simple hashes using crypto.subtle.digest()
.
const bytesArr = new TextEncoder().encode('Hello world');
const hash = await crypto.subtle.digest('SHA-256', bytesArr);
const base64Str = btoa(String.fromCharCode(...new Uint8Array(hash)));
console.log(base64Str);
Even if a hacker were to gain access to the database, they would only see the hashes. The attacker would need to figure out what starting text to use in a password field to gain access to a victim's account. If you've never seen hashing before, it might seem like an instant solution to securely storing passwords, but hackers have developed clever methods to crack passwords and have even learned how to leverage leaked hashes.
Password hacking methods
To understand the need for to-be-discussed password hashing methods, we need to review what kind of attacks hackers user to break passwords:
Brute force
Brute force is the most obvious attack method where a bad actor systematically attempts different passwords. In the event of a data breach, a bad actor can see what text will reproduce any of the leaked hashes. Although this method does not explicitly require a data breach, websites and applications with maximum password attempts and attempt timeouts render brute force attacks ineffective without a data leak. The drawback for hackers is it takes a lot of computing power and lots of time.
Can you determine the text that creates the SHA-256 hash
ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=?
Hash:
Keep trying
Precomputed tables
Datasets like Hash Tables and Rainbow Tables are pre-computed lists of hundreds of thousands or millions of hashes with their original text. They are faster and less computationally expensive than brute force attacks, but are weaker against long or complicated passwords.
Statistical/Probabilistic attacks
Bad actors can use previous data breaches and statistics to identify repeated hashes. As you can see below, the hash beginning with ZehL
repeats four times and the hash beginning with XohI
repeats twice. If an attacker knows that approximately 45% of passwords are "qwerty" and 15% are "password", an attacker can assume the accounts with ZehL
used "qwerty" and the accounts with XohI
used "password."
user | hash
--------------------
1 | ZehL4zUy+3h
2 | sAuJtSp0ekw
3 | ZehL4zUy+3h
4 | onpdOEJSwWi
6 | ZehL4zUy+3h
7 | ZehL4zUy+3h
8 | XohImNooBHF
9 | XohImNooBHF
10 | e1Ifsp052ZQ
As demonstrated, storing passwords as simple hashes leaves data vulnerable for a bad actor to guess what text will create a hash without even using login attempts. Hashing passwords requires specially engineered algorithms.
PBKDF2: A markedly better hashing algorithm
To defend against the previously mentioned attack methods, secure hashing algorithms for passwords are designed to add more randomness and are intentionally more computationally expensive. Several algorithms exist, including Argon2, bcrypt, scrypt and PBKDF2. Most of these rely on libraries to bring them into the Javascript ecosystem, however the native WebCrypto API supports PBKDF2. We'll use this method for demonstration purposes, but the same concepts exist in almost all modern password hashing algorithms. Before we can create a secure password hash, we will first need to generate a salt.
Adding a salt 🧂
To increase the randomness or entropy in a hash, we can add random data, called a salt. Instead of only hashing data from a password's text, we combine the random salt data with the data from the password, then hash the combination. Each password needs to have a unique salt to be effective. In WebCrypto we can create a cryptographically strong and unique salt with crypto.subtle.getRandomValues()
. A salt should be at least 16 bytes long.
const passwordString = 'abc123';
const salt = crypto.getRandomValues(new Uint8Array(16));
const passwordBuffer = new TextEncoder().encode(passwordString);
const payload = new Uint8Array([...salt, ...passwordBuffer]);
const hashBuffer = await crypto.subtle.digest('SHA-256', payload);
The benefit of using a unique salt is that two users could choose the same password and their resulting hashes will be completely different. You can see this in action below:
Salt 1:
Hash 1:
Salt 2:
Hash 2:
You aren't using salts 🧂
The salt and hash both need to be stored in a database so the same salt can be used to recompute the same hash when the user reenters their password.
With unique salts, the dataset will be secure against statistical attacks, Hash Tables and Rainbow Tables, but it is still possible to lookup the user's stored salt and hash to try to brute force their password. To further impede attackers, we can intentionally slow down the hashing algorithm.
Iterations 🌪
Brute forcing passwords is fairly labor intensive because of the sheer scale of possibilities. However modern computers can compute a SHA-256 or SHA-512 hash in less than a millisecond. This means a hacker who has possession of a victim's salt and hash could compute over 1,000 different possible hashes per second! Try to see how long it takes your browser to brute force the following hash and salt.
User's password:
bgaegh
Salt leaked from database:
UMX0cW8ogZ/DVRSba2mprA==
Password hash leaked from database:
Brute force not started, 0 password guesses
Computed hash
To make brute forcing for an attacker slower, we need to make our hashing function more computationally expensive to run. This can be accomplished by recomputing the hash, or hashing the hash over and over again. We call each of these hashing rounds an iteration. Usually the more iterations the better. In 2023 OWASP recommended using at least 600,000 iterations for SHA-256 and 200,000 iterations for SHA-512. You can test to see how much slower this is below.
User's password:
cbghbf
Salt leaked from database:
zcnNxXys1E2j9erSAtBBTg==
Password hash leaked from database:
Brute force not started, 0 password guesses
Computed hash
Up to this point, salting and high iterations is generally considered sufficiently secure to store user passwords. However in the event of a data breach, a hacker will have all the data points necessary to compute the user's password hash. As a an extra precaution, we can implement what is called a pepper.
Adding a pepper 🌶
Similar to a salt, a pepper is another set of random data. We also add this directly to the data derived from password text, then hash the combination. However there's two major differences with a pepper:
- The same pepper is typically used to hash every password
- The pepper cannot be stored where it would be exposed in the same data breach as passwords and salts
- While salting adds security, peppering without salting has no benefits. A pepper without a salt is the same as adding the same salts, which was discussed above.
Software engineers frequently store a pepper as an environment variable with other secrets and sensitive application data. Since this is always done on the server, there is some variability depending on which Javascript runtime you use. In run times like Deno and Bun, the implementation is more similar to ECMAScript standards. Here we are assuming the pepper is stored as a Base64 string. A 32 byte pepper should be sufficient.
const PEPPER = Uint8Array.from(atob(/* Deno or Bun */.env.PEPPER), (c) => c.charCodeAt(0)); // Deno and Bun
const PEPPER = Uint8Array.from(Buffer.from(process.env.PEPPER, 'base64'), (c) => c.charCodeAt(0)); // Node
const passwordText = 'abcdefg';
// it doesn't matter if the pepper or password data goes first
const combined = new Uint8Array([...new TextEncoder().encode(passwordText), ...PEPPER]);
// add salt and hash
The benefit of peppering is that as long as bad actors do not have access to the pepper, they won't be able to verify a password is correct with only the hash and salt. If your application implements maximum password attempts and/or attempt timeouts (which isn't covered here), brute forcing becomes a benign attack method.
User's password:
hcbajj
Salt leaked from database:
xLMNrlCl2sZ461cB2fNYlA==
Password hash leaked from database:
Pepper: ?????
Brute force not started, 0 password guesses
Computed hash
Using all of these defense measures will make it highly unpractical for bad actors to crack well crafted user passwords in your application.
Implementing in WebCrypto
🔐
We can put the previous concepts together to create a password hashing function in Javascript. With the native WebCrypto API, we will first create an HMAC key using the data from the password text combined with the pepper using crypto.subtle.importKey()
. This is pretty standard in the WebCrypto API anytime you want to create any password generated key or hash. Then we use that key along with the rest of the PBKDF2 options (hash, iterations and salt) to derive the final hash with crypto.subtle.deriveBits()
. Take a look below:
const PEPPER = Uint8Array.from(atob(Deno.env.PEPPER), (c) => c.charCodeAt(0));
// in node new Uint8Array(Buffer.from(process.env.PEPPER, 'base64'));
async function generatePbkdf2Hash(text) {
const key = await crypto.subtle.importKey(
"raw",
new Uint8Array([...new TextEncoder().encode(text), ...PEPPER]),
"PBKDF2",
false,
["deriveBits"],
);
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", iterations: 999_999, salt},
key,
256,
);
const base64Hash = btoa(String.fromCharCode(...new Uint8Array(hash))) // in node Buffer.from(hash, "binary").toString("base64");
const base64Salt = btoa(String.fromCharCode(...new Uint8Array(salt))); // in node Buffer.from(salt, "binary").toString("base64");
return { hash: base64Hash, salt: base64Salt };
}
Once we've made our function to hash our password, we can create a verify function that ensures an entered password and saved password match. In addition to the salt, the pepper, hashing algorithm and iterations will need to be the same to recreate the same hash.
const PEPPER = Uint8Array.from(atob(Deno.env.PEPPER), (c) => c.charCodeAt(0)); // in node new Uint8Array(Buffer.from(process.env.PEPPER, 'base64'));
async function verifyPassword(
text,
{ hash: storedBase64Hash, salt: storedBase64Salt },
) {
const key = await crypto.subtle.importKey(
"raw",
new Uint8Array([...new TextEncoder().encode(text), ...PEPPER]),
"PBKDF2",
false,
["deriveBits"],
);
const salt = Uint8Array.from(atob(storedBase64Salt), (c) => c.charCodeAt(0));
const hash = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 999_999, hash: "SHA-256" },
key,
256,
);
const base64Hash = btoa(String.fromCharCode(...new Uint8Array(hash)));
if (storedBase64Hash !== base64Hash) {
throw new Error('Invalid password')
}
return true;
}
You can test out how these functions work together below:
generatePbkdf2Hash
Output for generatePbkdf2Hash
{
hash: "",
salt: ""
}
verifyPassword
Output for verifyPassword
false
Longevity 🕔
One of the challenges of using password hashing is that you may need to eventually update your hashing mechanism. Maybe you want to increase the iterations in PBKDF2, maybe you want to use a new pepper or maybe you want to roll out a totally different hashing algorithm. When this happens you'll need to re-hash a password with your new method the next time the user successfully enters their credentials. Software engineers employ different methods of deducing if a user's password is using the latest hashing implementation such as storing a version number with the salt and hash in the database or trying to verify a user's password against both the new and old implementation. One of the benefits of rolling out your own hashing implementations is you have granular control over this process.
The function below is a simplified method of what you could use to update a password hash:
/* Hypothetical scenario */
// const password = "abc123";
// const pepper1 = genRandomBytes(32);
// const pepper2 = genRandomBytes(32);
// const storedHashAndSalt = {
// hash: "jBy6S/yVYpiOyk8MVQMnsH4RyWhODzcuEMdawZ8431o=",
// salt: "kFfSgKVk4zj9w41dDf07ug==",
// };
// const oldVerify = () =>
// verifyPassword(password, storedHashAndSalt, {
// algorithm: "SHA-256",
// iterations: 999_999,
// pepper: pepper1,
// });
// const newVerify = () =>
// verifyPassword(password, storedHashAndSalt, {
// algorithm: "SHA-512",
// iterations: 700_000,
// pepper: pepper2,
// });
async function verifyAndMigrateHash(
{ oldVerify, newVerify, migrateCallback },
) {
const [didVerifyOld, didVerifyNew] = await Promise.allSettled([
oldVerify(),
newVerify(),
]);
if (didVerifyOld.status === "fulfilled" && didVerifyOld.value === true) {
await migrateCallback();
return true;
} else if (
didVerifyNew.status === "fulfilled" && didVerifyNew.value === true
) {
return true;
}
throw new Error("Invalid password");
}
Conclusion
We've seen leaps and bounds in proper password hashing over the last two decades, but the security of your users' data and your application will depend on your implementation. Following these industry standards is a simple way to provide peace of mind for your user data and the security of your application.