How are hashed passwords not the same even if the original passwords are.

How are hashed passwords not the same even if the original passwords are?

为什么即使原始密码相同,哈希后的密码却不同?

I often wondered that if I have entered a password and someone else has coincidentally entered the same password, then after hashing the passwords would have been same. But I was wrong. There is a technique known as “salting” which, as its name suggests, adds some salt to the content (the password). Salting in security is the practice of adding unique, random data (a “salt”) to each user’s password before it is hashed and stored. For robust implementation, best practices require using unique per-user salts of at least 16 bytes paired with modern algorithms and defense-in-depth techniques like peppering.

我经常想,如果我输入了一个密码,而其他人碰巧也输入了相同的密码,那么在哈希处理后,这些密码应该是相同的。但我错了。有一种技术叫做“加盐”(salting),顾名思义,它会给内容(密码)添加一些“盐”。在安全领域,加盐是指在每个用户的密码被哈希并存储之前,向其添加唯一的随机数据(即“盐”)。为了实现稳健的安全性,最佳实践要求使用至少 16 字节的唯一用户盐值,并结合现代算法以及诸如“加胡椒”(peppering)等纵深防御技术。

This is how salting works in steps: 以下是加盐的工作步骤:

  • Generating the salt - it uses CSPRNG which is Cryptographically Secure Random Number Generator, creates a unique, random string for each user account.
  • 生成盐值 - 使用 CSPRNG(加密安全伪随机数生成器),为每个用户账户创建一个唯一的随机字符串。
  • Combine password and salt - note, that this step is done before hashing which makes the hashed passwords different.
  • 组合密码与盐值 - 请注意,此步骤在哈希之前完成,这使得哈希后的密码变得不同。
  • Hashing the combined strings - this is where the passwords are hashed, run the result through Argon2id, bcrypt, or scrypt.
  • 哈希组合后的字符串 - 这是对密码进行哈希处理的环节,将结果通过 Argon2id、bcrypt 或 scrypt 算法进行处理。
  • Storing the salt and the hash - both of these go into the user’s records for verification.
  • 存储盐值和哈希值 - 两者都会存入用户记录中,以便后续验证。
  • Verifying while logging - the entered password is rehashed and compared with the stored password for a match.
  • 登录时验证 - 将输入的密码重新哈希,并与存储的哈希值进行比对以确认是否匹配。

Seeing it in code (Node.js)

代码演示 (Node.js)

Below is a simple Node.js example using the built-in crypto module (no external dependencies needed). It walks through the exact same five steps described above. 以下是一个简单的 Node.js 示例,使用了内置的 crypto 模块(无需外部依赖)。它完整演示了上述五个步骤。

Step 1: Generate the salt We use crypto.randomBytes, which is a CSPRNG, to create a unique, random salt for each user. 第一步:生成盐值 我们使用 crypto.randomBytes(一个 CSPRNG)为每个用户创建一个唯一的随机盐值。

const crypto = require('crypto');

function generateSalt(length = 16) {
  return crypto.randomBytes(length).toString('hex');
}

Step 2 & 3: Combine the password with the salt, then hash The salt is combined with the password before hashing. Here we use scrypt, a memory-hard hashing algorithm similar in spirit to bcrypt/Argon2id. 第二步与第三步:组合密码与盐值,然后哈希 在哈希之前,盐值会与密码结合。这里我们使用 scrypt,这是一种内存密集型哈希算法,其设计理念与 bcrypt/Argon2id 类似。

function hashPassword(password, salt) {
  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
  return hash;
}

Step 4: Store the salt and hash Both the salt and the resulting hash are saved to the user’s record (in a real app, this would go into a database). 第四步:存储盐值和哈希值 盐值和生成的哈希值都会保存到用户记录中(在实际应用中,这会存入数据库)。

function createUserRecord(password) {
  const salt = generateSalt();
  const hash = hashPassword(password, salt);
  return { salt, hash }; // store both in the user's record
}

Step 5: Verify the password on login When a user logs in, we rehash their entered password using the stored salt, and compare it against the stored hash. 第五步:登录时验证密码 当用户登录时,我们使用存储的盐值对输入的密码进行重新哈希,并将其与存储的哈希值进行比较。

function verifyPassword(inputPassword, storedSalt, storedHash) {
  const inputHash = hashPassword(inputPassword, storedSalt);
  return crypto.timingSafeEqual(
    Buffer.from(inputHash, 'hex'),
    Buffer.from(storedHash, 'hex')
  );
}

We use crypto.timingSafeEqual instead of a plain === comparison to avoid timing attacks - this is a small example of the “defense-in-depth” approach mentioned earlier. 我们使用 crypto.timingSafeEqual 而不是简单的 === 比较,以避免计时攻击——这是前面提到的“纵深防御”方法的一个小例子。

Putting it together: proving the point

总结:验证结论

This is the part that answers the question I started with - do two identical passwords really produce different hashes? 这一部分回答了我最初的问题——两个相同的密码真的会产生不同的哈希值吗?

const password1 = "mySecret123";
const password2 = "mySecret123"; // same password as user 1

const user1 = createUserRecord(password1);
const user2 = createUserRecord(password2);

console.log("User 1 salt:", user1.salt);
console.log("User 1 hash:", user1.hash);
console.log("User 2 salt:", user2.salt);
console.log("User 2 hash:", user2.hash);

console.log("Hashes are different even though passwords match:", user1.hash !== user2.hash);

// Login verification
const isValid = verifyPassword("mySecret123", user1.salt, user1.hash);
console.log("Password verification result:", isValid);

Even though password1 and password2 are identical strings, the random salt generated for each user makes the stored hashes completely different - which is exactly the mechanism that answers the doubt I had at the start. 尽管 password1password2 是相同的字符串,但为每个用户生成的随机盐值使得存储的哈希值完全不同——这正是解答我最初疑惑的机制。

Note: For production applications, dedicated libraries like bcrypt or argon2 (available as npm packages) wrap this logic with sensible, battle-tested defaults and are generally preferred over hand-rolling scrypt calls. Since this post covers all three algorithms conceptually, the native crypto module was used here to keep the example dependency-free and easy to follow. 注意: 对于生产环境的应用,建议使用 bcryptargon2 等专用库(可通过 npm 包获取),它们封装了经过实战检验的合理默认设置,通常优于手动编写 scrypt 调用。由于本文从概念上涵盖了这三种算法,因此这里使用了原生的 crypto 模块,以保持示例无依赖且易于理解。