š How to Handle Password Hashing in Node.js
If youāre building a Node.js application that handles passwords, itās important to know how to deal with password hashing. In this post, weāll explore the usage of the node:crypto
module to hash and verify passwords, with a focus on secure practices.
Introduction to node:crypto
Node.js provides the node:crypto
module, a powerful tool for encryption, hashing, and securing data. This module includes functions for hashing passwords using established algorithms like PBKDF2, scrypt, bcrypt, and more. Proper use of crypto
ensures that passwords are securely hashed, making it significantly harder for attackers to recover user credentials in the event of a data breach.
In this post, weāll focus on using PBKDF2 and scrypt, two robust hashing algorithms that are widely recommended.
How does hashing work
Hashing is a one-way function that converts data into a fixed-length value. When implemented correctly, hashing ensures that even if an attacker gets access to your database, they wonāt be able to easily recover the original passwords.
To make hashing secure, we need 3 things that weāll further explore later:
- Use unique salts for each password to prevent attacks using precomputed hashes (called rainbow tables).
- Adaptive costs: configure parameters that allow to increase the computational cost over time to make attacks impractical.
- Use algorithms that are resilient against brute-force and parallelized attacks.
Using PBKDF2 to Hash Passwords
PBKDF2 (Password-Based Key Derivation Function 2) is a widely-used algorithm for hashing passwords. It uses a combination of key stretching (multiple iterations) and salting to secure passwords.
Hereās how to hash a password using PBKDF2 with node:crypto
:
const { pbkdf2Sync, randomBytes } = require('node:crypto');
function hashPassword(password) {
const salt = randomBytes(16).toString('hex');
const iterations = 100000;
const keylen = 64;
const digest = 'sha512';
const hash = pbkdf2Sync(password, salt, iterations, keylen, digest).toString('hex');
return `${salt}:${iterations}:${hash}`;
}
console.log(hashPassword('my_secure_password'));
pbkdf2Sync()
is a function that generates a hashed password using a specified number of iterations.
It uses a unique salt that we generate for each password using randomBytes()
. Itās a random string that ensures that each password hash is unique, even if the passwords are identical.
We use 100,000 iterations, which adds computational cost to make brute-force attacks more difficult. This is the minimum value to slow down attackers while still being usable for legitimate users. The higher the iterations count, the longer it takes to compute the hash. It should take at least 100ms to compute the hash, balancing usability and security.
As hardware continues to improve, you can increase the iteration count because of the increasing computational power.
Using Scrypt for Better Security
Scrypt is a powerful choice for password hashing and is often preferred over PBKDF2 because of its memory-hard nature. Memory-hard algorithms are resistant to attacks using specialized hardware like GPUs because they require a lot of memory, which is expensive to parallelize.
Hereās how to hash a password using scrypt:
const { scryptSync, randomBytes } = require('node:crypto');
function hashPasswordScrypt(password) {
const salt = randomBytes(16).toString('hex');
const keylen = 64;
const hash = scryptSync(password, salt, keylen).toString('hex');
return `${salt}:${hash}`;
}
console.log(hashPasswordScrypt('my_secure_password'));
PBKDF2 vs Scrypt: Which One Should You Use?
Both PBKDF2 and scrypt are good options for password hashing, but each has distinct use cases and benefits.
PBKDF2 is NIST-approved (National Institute of Standards and Technology) and has widespread support across different libraries and platforms. You may want to use it in case you need to adhere to high security standards, especially in regulated industries like healthcare or finance.
Scrypt is a memory-hard algorithm, meaning it requires significant memory in addition to CPU power. This makes it more resistant to attacks using specialized hardware like GPUs. It is ideal for scenarios where security is more important than performance, such as securing highly sensitive user information.
Performance and Resource Considerations
The node:crypto module also offers the scrypt
and the pbkdf2
functions that are asynchronous and take a callback function as last argument to call when they are done, this avoids to block the event loop, which could degrade the responsiveness of your application.
Using scrypt
with async/await
to avoid blocking the event loop
To use scrypt without blocking the event loop, you can easily leverage async/await
with the asynchronous scrypt
function. This allows your code to wait for the hashing to complete without blocking the entire event loop, meaning other requests can be processed concurrently.
Hereās an updated version using scrypt
with async/await
:
const { scrypt, randomBytes } = require('node:crypto');
async function hashPasswordScryptAsync(password) {
const salt = randomBytes(16).toString('hex');
const keylen = 64;
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, (err, derivedKey) => {
if (err) reject(err);
resolve(`${salt}:${derivedKey.toString('hex')}`);
});
});
}
async function route(){
const hashedPassword = await hashPasswordScryptAsync('my_secure_password');
console.log('Hashed Password:', hashedPassword);
};
route();
The scrypt
function does not return a Promise, but we can wrap it in one so we can use await
to handle the result without blocking the event loop.
This approach ensures that while the hashing operation is running, the event loop is free to handle other requests.
Verifying Passwords Securely
To verify a password, you must hash the provided password with the same salt and compare it to the stored hash. Hereās a function to do that with PBKDF2:
function checkPassword(password, savedPassword) {
const [salt, iterations, key] = savedPassword.split(':');
const hash = pbkdf2Sync(password, salt, parseInt(iterations), 64, 'sha512').toString('hex');
return hash === key;
}
How to avoid Timing Attacks
The direct comparison (hash === key
) can be vulnerable to timing attacks, where attackers can measure the time it takes to return different responses. To mitigate this, use crypto.timingSafeEqual()
for comparison:
const { timingSafeEqual } = require('node:crypto');
function checkPasswordSafe(password, savedPassword) {
const [salt, iterations, key] = savedPassword.split(':');
const hash = pbkdf2Sync(password, salt, parseInt(iterations), 64, 'sha512');
const keyBuffer = Buffer.from(key, 'hex');
return timingSafeEqual(hash, keyBuffer);
}
With scrypt
it would be:
const { timingSafeEqual } = require('node:crypto');
function checkPasswordSafe(password, savedPassword) {
const [salt, key] = savedPassword.split(':');
const keylen = 64;
const hash = scryptSync(password, salt, keylen);
const keyBuffer = Buffer.from(key, 'hex');
return timingSafeEqual(hash, keyBuffer);
}
We can do the same with their equivalent asynchronous functions.
Conclusion
The node:crypto
module provides powerful tools to help you securely hash and verify passwords. Choose an algorithm that works best for your use case and make sure to update the parameters as technology evolves.