跳至主要內容
Skip to content

密碼如何安全儲存

密碼儲存是登入系統最不能犯錯的地方。使用者把密碼交給系統,不代表系統應該「知道」這個密碼;成熟系統應該只能驗證密碼是否正確,而不能還原出使用者的原始密碼。

一句話回答:密碼不能明文儲存,也不應該用一般快速 hash;應使用專為密碼設計的慢速、可調成本、最好具備記憶體成本的 password hashing 演算法,例如 Argon2id、scrypt、bcrypt 或 PBKDF2,並為每個密碼使用唯一 salt,必要時搭配儲存在資料庫外的 pepper;同時要保存演算法與成本參數,讓未來可以升級 hash,並在外洩時能撤銷 session、要求改密碼與通知使用者。


一、 必考觀念

1. 密碼儲存的目標不是保密而已

很多人會以為:

我把密碼加密存起來,需要時再解密比對就好。

這是錯的方向。

密碼儲存的目標是:

text
系統能驗證使用者輸入的密碼是否正確,
但即使資料庫外洩,攻擊者也不能輕易還原原始密碼。

所以密碼應該用不可逆的 password hash,而不是可逆加密。

2. Hash 和加密不同

做法可逆嗎適合存密碼嗎說明
明文可直接看到不適合資料庫外洩等於密碼全外洩
加密有 key 可解密通常不適合key 外洩就能還原全部密碼
一般 hash不可逆但太快不適合SHA-256 太快,容易被大量猜測
Password hash不可逆且成本高適合Argon2id、bcrypt、scrypt、PBKDF2

重點不是「不可逆」而已,還要讓每一次猜密碼都很貴。

3. 攻擊者真正做的是離線猜密碼

假設資料庫外洩,攻擊者拿到:

text
email
passwordHash
salt
algorithm

他不能直接反推出密碼,但可以離線猜:

text
猜 password = 12345678
-> 用同樣 salt 與演算法算 hash
-> 比對是否等於資料庫中的 passwordHash

如果你用 SHA-256,攻擊者可以非常快速地大量猜測。Password hashing 的目的,就是讓每次猜測變慢、變貴、變得不划算。

OWASP Password Storage Cheat Sheet 也明確建議使用 Argon2id、bcrypt、PBKDF2 等強且慢的密碼雜湊演算法,而不是一般快速 hash。


二、 Salt 是什麼?

1. Salt 是每個密碼的隨機值

Salt 是為每個密碼產生的隨機值:

text
password + unique salt -> password hash

例如:

typescript
type PasswordCredential = {
  userId: string
  passwordHash: string
  algorithm: 'argon2id'
  salt: string
  parameters: {
    memory: number
    iterations: number
    parallelism: number
  }
}

實務上很多演算法格式會把 salt、參數與 hash 編在同一個字串裡,例如 Argon2id 的 encoded hash。

2. Salt 解決什麼問題?

如果沒有 salt,兩個使用者用同一個密碼,hash 會一樣:

text
alice: hash("password123") = abc
bob:   hash("password123") = abc

攻擊者一看就知道兩人密碼相同。

有 salt 後:

text
alice: hash("password123" + saltA) = abc
bob:   hash("password123" + saltB) = xyz

即使密碼相同,hash 也不同。

Salt 的作用:

  • 避免相同密碼產生相同 hash。
  • 對抗預先計算好的 rainbow table。
  • 讓攻擊者必須針對每個使用者分開猜。

3. Salt 不需要保密

Salt 通常可以和 password hash 一起存在資料庫。

這點很容易被誤解:

Salt 的重點不是保密,而是唯一與隨機。

真正需要保密的是 pepper。


三、 Pepper 是什麼?

1. Pepper 是系統層級的秘密

Pepper 是額外加入密碼處理流程的秘密值,通常不放在資料庫。

text
password + salt + pepper -> password hash

或:

text
password -> password hash
password hash + pepper -> HMAC

Pepper 應該放在:

  • 環境變數。
  • Secret Manager。
  • KMS。
  • HSM。
  • 受控的安全設定服務。

不要和 password hash 放同一張資料表。

2. Pepper 的價值是防禦深度

如果只有資料庫外洩,而 pepper 沒外洩,攻擊者就算拿到 hash,也更難離線猜測。

但 pepper 不是萬靈丹:

  • 如果應用伺服器也被攻破,pepper 可能一起外洩。
  • Pepper 輪換很麻煩,因為需要重新處理密碼 hash。
  • Pepper 不能取代 salt。
  • Pepper 不能取代 password hashing algorithm。

OWASP 對 pepper 的定位也是 defense in depth,而不是單靠它就安全。

3. Pepper 輪換要先設計

Pepper 外洩時怎麼辦?

常見策略:

  • 要求使用者下次登入時重設密碼。
  • 支援 pepper version。
  • 登入成功時重新 hash。
  • 重大事件時撤銷 session / refresh token。

如果完全沒設計 pepper 版本,輪換會非常痛苦。


四、 該選哪個演算法?

1. 優先考慮 Argon2id

Argon2id 是目前常被推薦的現代 password hashing 選擇之一,特點是可以設定:

  • memory cost。
  • time cost。
  • parallelism。

概念:

typescript
await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 19456,
  timeCost: 2,
  parallelism: 1,
})

OWASP Password Storage Cheat Sheet 目前建議 Argon2id,並給出最低設定建議:19 MiB memory、iteration count 2、parallelism 1。實務上仍要依照伺服器硬體、登入流量與延遲預算做壓測。

2. bcrypt 仍常見,但要知道限制

bcrypt 在很多系統仍然常見:

typescript
const hash = await bcrypt.hash(password, 12)
const ok = await bcrypt.compare(password, hash)

要注意:

  • cost factor 要可調。
  • bcrypt 對輸入長度有 72 bytes 限制。
  • Unicode / emoji / 多位元組字元要小心。
  • 新系統若可選,通常會優先評估 Argon2id。

如果舊系統已經用 bcrypt,不代表要立刻粗暴遷移;可以先確保 cost 合理、沒有明文或快速 hash,再規劃逐步升級。

3. scrypt 與 PBKDF2

scrypt 也是適合密碼儲存的 KDF,具備記憶體成本。

PBKDF2 常出現在需要 FIPS 相容的場景,但要有足夠高的 iteration count。OWASP 對 FIPS 場景建議 PBKDF2-HMAC-SHA-256 搭配高工作因子。

簡化選型:

演算法適合場景
Argon2id新系統優先評估
scryptArgon2id 不可用時的選項
bcrypt既有系統常見,注意 72 bytes 限制
PBKDF2FIPS 或特定合規環境常見
SHA-256 / MD5不適合密碼儲存

4. 不要自己發明演算法

不要做:

typescript
sha256(password + salt)
sha256(sha256(password))
md5(password)
aesEncrypt(password)
base64(password)

也不要覺得多 hash 幾次就等於安全。密碼儲存要用被廣泛審查、可設定成本、框架支援良好的演算法。


五、 成本參數與升級

1. Work factor 是安全旋鈕

Password hashing 演算法通常有成本參數:

演算法常見成本
Argon2idmemoryCost、timeCost、parallelism
bcryptcost factor
scryptN、r、p
PBKDF2iterations

成本越高,攻擊者猜密碼越貴;但你的登入伺服器也會更慢。

所以要壓測:

text
單次 hash 花多久?
尖峰登入量多少?
CPU / memory 是否撐得住?
失敗登入攻擊時會不會拖垮服務?

成本參數不是越高越好,而是要高到足以增加攻擊成本,同時不讓正常登入不可用。

2. 成本要能逐步升級

今天合理的成本,幾年後可能太低。

所以 password hash 應該帶版本與參數:

text
$argon2id$v=19$m=19456,t=2,p=1$...

或資料庫記:

typescript
type PasswordHashRecord = {
  userId: string
  algorithm: 'argon2id'
  hash: string
  parameters: string
  createdAt: string
}

登入成功時可以檢查:

typescript
if (passwordHasher.needsRehash(user.passwordHash)) {
  user.passwordHash = await passwordHasher.hash(input.password)
  await userRepository.save(user)
}

這叫 opportunistic rehash。

3. 舊 hash 遷移

很多老系統可能有:

text
md5(password)
sha1(password)
sha256(password + salt)
低成本 bcrypt

遷移方式:

  1. 登入時用舊方式驗證。
  2. 驗證成功後立刻用新演算法重新 hash。
  3. 儲存新的 hash 與 algorithm version。
  4. 長期未登入使用者可在下次密碼重設時升級。

不要要求所有使用者同一天全部改密碼,除非已經發生重大外洩或舊 hash 風險太高。


六、 密碼建立與修改

1. 新密碼要擋常見與外洩密碼

密碼儲存不只發生在登入,也發生在:

  • 註冊。
  • 修改密碼。
  • 忘記密碼重設。
  • 管理員重設密碼。

建立密碼時應檢查:

  • 長度是否足夠。
  • 是否在常見密碼清單。
  • 是否已知外洩。
  • 是否和帳號資訊太相似。
  • 是否符合產品風險等級。

NIST SP 800-63B 建議在建立或修改密碼時,將候選密碼與常見、預期或已外洩密碼清單比對。

2. 不要過度依賴複雜度規則

傳統規則常見:

text
至少一個大寫
至少一個小寫
至少一個數字
至少一個符號
每 90 天強制更換

這些規則可能讓使用者創造可預測密碼:

text
Password1!
Summer2026!

更好的方向:

  • 鼓勵長密碼。
  • 允許密碼管理器貼上。
  • 擋常見與外洩密碼。
  • 提供密碼強度提示。
  • 有外洩或可疑活動時才要求更換。
  • 鼓勵 MFA 或 Passkey。

具體政策仍要看合規要求與產品場景。

3. 修改密碼要撤銷舊登入狀態

使用者修改密碼後,通常要:

  • 更新 password hash。
  • 更新 passwordChangedAt。
  • 撤銷其他裝置 session。
  • 撤銷 refresh token。
  • 發送安全通知。
  • 寫 audit log。

如果密碼被盜,攻擊者可能已經登入。只改 hash 不撤銷 session,風險還在。


七、 資料庫設計

1. 不要把密碼欄位放進普通 user DTO

可以分離:

typescript
type User = {
  id: string
  email: string
  status: 'active' | 'disabled'
  createdAt: string
}

type UserCredential = {
  userId: string
  passwordHash: string
  passwordAlgorithm: 'argon2id' | 'bcrypt'
  passwordUpdatedAt: string
}

好處:

  • API 不容易誤回 passwordHash。
  • 權限邊界更清楚。
  • 安全欄位可以限制存取。
  • 未來支援第三方登入、Passkey 更自然。

2. 支援多種登入憑證

現代帳號可能同時有:

  • password。
  • Google identity。
  • LINE identity。
  • passkey credential。
  • TOTP secret。
  • backup codes。

所以可以把 credentials 抽成獨立概念:

typescript
type CredentialType =
  | 'password'
  | 'oauth_google'
  | 'oauth_line'
  | 'passkey'
  | 'totp'

type Credential = {
  id: string
  userId: string
  type: CredentialType
  secretHash?: string
  providerUserId?: string
  createdAt: string
  lastUsedAt?: string
}

密碼只是多種身份驗證方式之一。

3. Log 裡不能出現敏感值

避免記錄:

  • 原始 password。
  • passwordHash。
  • salt。
  • pepper。
  • reset token。
  • MFA secret。
  • refresh token。

登入失敗 log 可以記:

text
identifierHash
reason
ip
userAgent
requestId

但不要把安全材料塞進 log 系統。


八、 資料外洩時怎麼辦?

1. 外洩等級要先判斷

不同外洩範圍,處理不同:

外洩內容風險
只有 user profile隱私風險
password hash + salt離線猜密碼風險
password hash + salt + pepper更嚴重,防線大幅降低
refresh token / session可直接冒用登入
原始密碼最嚴重,代表儲存設計失敗

如果外洩包含 password hash,即使用了強 hash,也要嚴肅處理,因為弱密碼仍可能被破解。

2. 應對措施

常見處理:

  • 立即封鎖攻擊路徑。
  • 輪換 pepper / secrets。
  • 撤銷 refresh token 與可疑 session。
  • 要求高風險使用者重設密碼。
  • 通知使用者。
  • 監控 credential stuffing。
  • 提醒不要重用密碼。
  • 產出事件報告與改進計畫。

是否強制所有人改密碼,要看外洩範圍、hash 強度、是否有 pepper、是否有跡象顯示 hash 被破解。

3. 強 hash 是爭取時間

Password hashing 不是讓外洩無害,而是爭取時間:

text
讓攻擊者破解變慢
讓團隊有時間撤銷 session
讓使用者有時間改密碼
降低弱密碼被快速批量破解的速度

所以不要因為用了 Argon2id 就忽略事件應變。


九、 追問題庫

Q1:為什麼密碼不能加密後存?

因為加密是可逆的,只要 key 外洩,所有密碼都能被解出來。密碼儲存通常不需要還原原文,只需要驗證輸入是否正確,所以應該用不可逆且成本高的 password hash。

Q2:為什麼 SHA-256 不適合存密碼?

SHA-256 是快速 hash,適合資料完整性、簽章等場景,不適合密碼儲存。密碼常常熵不足,攻擊者可以大量猜測;快速 hash 會讓離線暴力破解成本太低。

Q3:Salt 和 pepper 差在哪?

Salt 是每個密碼唯一的隨機值,通常和 hash 一起存,不需要保密;pepper 是系統層級秘密,應放在資料庫外,例如 Secret Manager 或 HSM。Salt 防止相同密碼產生相同 hash,pepper 提供資料庫外洩時的額外防線。

Q4:bcrypt 還能用嗎?

很多既有系統仍使用 bcrypt,只要成本參數合理,仍比快速 hash 好很多。但要注意 72 bytes 輸入限制與成本升級。新系統可以優先評估 Argon2id。

Q5:密碼 hash 成本越高越好嗎?

不是。

成本越高,攻擊者越難猜,但你的伺服器登入也越慢。要根據硬體、登入流量、延遲要求和抗攻擊能力壓測,選擇能承受的最高合理成本。

Q6:舊系統用 MD5 怎麼辦?

不要只停在「以後新密碼用新演算法」。應該支援 algorithm version,登入時先用舊 hash 驗證,成功後立刻重新用新演算法 hash。高風險情況下要要求重設密碼。


十、 實作題

題目:設計一個 PasswordHasher

需求:

  • 新密碼使用 Argon2id。
  • 驗證時支援舊 bcrypt。
  • 登入成功後可以判斷是否需要 rehash。
  • 不把 pepper 放資料庫。

介面可以這樣設計:

typescript
type HashResult = {
  encodedHash: string
}

type PasswordHasher = {
  hash(password: string): Promise<HashResult>
  verify(password: string, encodedHash: string): Promise<boolean>
  needsRehash(encodedHash: string): boolean
}

使用:

typescript
async function verifyLoginPassword(user: UserCredential, password: string) {
  const passwordValid = await passwordHasher.verify(
    password,
    user.passwordHash,
  )

  if (!passwordValid) {
    return false
  }

  if (passwordHasher.needsRehash(user.passwordHash)) {
    const result = await passwordHasher.hash(password)

    await credentialRepository.updatePasswordHash({
      userId: user.userId,
      passwordHash: result.encodedHash,
    })
  }

  return true
}

Pepper 可以由 PasswordHasher 從 Secret Manager 或環境注入,不要存在 user table。

題目延伸:修改密碼流程

修改密碼時要做:

text
驗證目前密碼或完成 step-up authentication
-> 檢查新密碼是否常見或已外洩
-> 產生新的 password hash
-> 更新 passwordUpdatedAt
-> 撤銷其他 session / refresh token
-> 寫 audit log
-> 通知使用者

這能把 password storage 和 session management 串起來。


十一、 資深視角

1. 密碼安全是多層防線

安全儲存密碼只是其中一層。

還要有:

  • 強密碼引導。
  • 外洩密碼檢查。
  • rate limit。
  • MFA。
  • 安全 session 管理。
  • audit log。
  • 異常登入偵測。
  • incident response。

不要期待單一技術解決所有密碼風險。

2. 可升級性非常重要

密碼 hash 不是一次設計永遠不變。

你要能升級:

  • 演算法。
  • 成本參數。
  • pepper 版本。
  • password policy。
  • session revoke 策略。

所以一開始就要保存 algorithm、parameter、version,否則未來會被舊設計綁住。

3. 安全策略要配合產品風險

部落格、電商、金融、醫療、政府服務需要的密碼策略不同。

高風險場景要更重視:

  • MFA。
  • 高風險操作重新驗證。
  • 實名或高信任身份。
  • 稽核紀錄。
  • 法規要求。
  • 安全事件通知。

但低風險場景也不能明文存密碼。底線不能退。


總結

問題回答重點
密碼能不能明文存絕對不行
加密和 hash加密可逆,密碼應用不可逆 password hash
SHA-256太快,不適合密碼儲存
Salt每個密碼唯一,不需保密,防 rainbow table 和相同 hash
Pepper系統層級秘密,放資料庫外,提供防禦深度
演算法優先評估 Argon2id,也可用 scrypt、bcrypt、PBKDF2
成本參數要壓測,並能隨時間升級
舊 hashalgorithm version + 登入成功後 rehash
外洩應對撤銷 session、通知、重設密碼、監控攻擊

一句完整的面試回答可以是:

密碼不能明文儲存,也不應該可逆加密後存,因為系統只需要驗證密碼,不需要還原密碼。正確做法是使用專門的 password hashing 演算法,例如 Argon2id、scrypt、bcrypt 或 PBKDF2,為每個密碼使用唯一 salt,並設定合理成本參數,讓離線猜密碼變得昂貴。Salt 不需要保密,可以和 hash 一起存;pepper 是系統層級秘密,應放在資料庫外,例如 Secret Manager 或 HSM,作為防禦深度。不要用 SHA-256、MD5 或自己發明多層 hash。Hash 結果要保存演算法與參數,方便未來提高成本或遷移演算法;登入成功時可以做 needsRehash。若發生外洩,要根據外洩範圍撤銷 session、輪換秘密、要求重設密碼並通知使用者。

延伸閱讀