密碼如何安全儲存
密碼儲存是登入系統最不能犯錯的地方。使用者把密碼交給系統,不代表系統應該「知道」這個密碼;成熟系統應該只能驗證密碼是否正確,而不能還原出使用者的原始密碼。
一句話回答:密碼不能明文儲存,也不應該用一般快速 hash;應使用專為密碼設計的慢速、可調成本、最好具備記憶體成本的 password hashing 演算法,例如 Argon2id、scrypt、bcrypt 或 PBKDF2,並為每個密碼使用唯一 salt,必要時搭配儲存在資料庫外的 pepper;同時要保存演算法與成本參數,讓未來可以升級 hash,並在外洩時能撤銷 session、要求改密碼與通知使用者。
一、 必考觀念
1. 密碼儲存的目標不是保密而已
很多人會以為:
我把密碼加密存起來,需要時再解密比對就好。
這是錯的方向。
密碼儲存的目標是:
系統能驗證使用者輸入的密碼是否正確,
但即使資料庫外洩,攻擊者也不能輕易還原原始密碼。所以密碼應該用不可逆的 password hash,而不是可逆加密。
2. Hash 和加密不同
| 做法 | 可逆嗎 | 適合存密碼嗎 | 說明 |
|---|---|---|---|
| 明文 | 可直接看到 | 不適合 | 資料庫外洩等於密碼全外洩 |
| 加密 | 有 key 可解密 | 通常不適合 | key 外洩就能還原全部密碼 |
| 一般 hash | 不可逆但太快 | 不適合 | SHA-256 太快,容易被大量猜測 |
| Password hash | 不可逆且成本高 | 適合 | Argon2id、bcrypt、scrypt、PBKDF2 |
重點不是「不可逆」而已,還要讓每一次猜密碼都很貴。
3. 攻擊者真正做的是離線猜密碼
假設資料庫外洩,攻擊者拿到:
email
passwordHash
salt
algorithm他不能直接反推出密碼,但可以離線猜:
猜 password = 12345678
-> 用同樣 salt 與演算法算 hash
-> 比對是否等於資料庫中的 passwordHash如果你用 SHA-256,攻擊者可以非常快速地大量猜測。Password hashing 的目的,就是讓每次猜測變慢、變貴、變得不划算。
OWASP Password Storage Cheat Sheet 也明確建議使用 Argon2id、bcrypt、PBKDF2 等強且慢的密碼雜湊演算法,而不是一般快速 hash。
二、 Salt 是什麼?
1. Salt 是每個密碼的隨機值
Salt 是為每個密碼產生的隨機值:
password + unique salt -> password hash例如:
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 會一樣:
alice: hash("password123") = abc
bob: hash("password123") = abc攻擊者一看就知道兩人密碼相同。
有 salt 後:
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 是額外加入密碼處理流程的秘密值,通常不放在資料庫。
password + salt + pepper -> password hash或:
password -> password hash
password hash + pepper -> HMACPepper 應該放在:
- 環境變數。
- 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。
概念:
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 在很多系統仍然常見:
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 | 新系統優先評估 |
| scrypt | Argon2id 不可用時的選項 |
| bcrypt | 既有系統常見,注意 72 bytes 限制 |
| PBKDF2 | FIPS 或特定合規環境常見 |
| SHA-256 / MD5 | 不適合密碼儲存 |
4. 不要自己發明演算法
不要做:
sha256(password + salt)
sha256(sha256(password))
md5(password)
aesEncrypt(password)
base64(password)也不要覺得多 hash 幾次就等於安全。密碼儲存要用被廣泛審查、可設定成本、框架支援良好的演算法。
五、 成本參數與升級
1. Work factor 是安全旋鈕
Password hashing 演算法通常有成本參數:
| 演算法 | 常見成本 |
|---|---|
| Argon2id | memoryCost、timeCost、parallelism |
| bcrypt | cost factor |
| scrypt | N、r、p |
| PBKDF2 | iterations |
成本越高,攻擊者猜密碼越貴;但你的登入伺服器也會更慢。
所以要壓測:
單次 hash 花多久?
尖峰登入量多少?
CPU / memory 是否撐得住?
失敗登入攻擊時會不會拖垮服務?成本參數不是越高越好,而是要高到足以增加攻擊成本,同時不讓正常登入不可用。
2. 成本要能逐步升級
今天合理的成本,幾年後可能太低。
所以 password hash 應該帶版本與參數:
$argon2id$v=19$m=19456,t=2,p=1$...或資料庫記:
type PasswordHashRecord = {
userId: string
algorithm: 'argon2id'
hash: string
parameters: string
createdAt: string
}登入成功時可以檢查:
if (passwordHasher.needsRehash(user.passwordHash)) {
user.passwordHash = await passwordHasher.hash(input.password)
await userRepository.save(user)
}這叫 opportunistic rehash。
3. 舊 hash 遷移
很多老系統可能有:
md5(password)
sha1(password)
sha256(password + salt)
低成本 bcrypt遷移方式:
- 登入時用舊方式驗證。
- 驗證成功後立刻用新演算法重新 hash。
- 儲存新的 hash 與 algorithm version。
- 長期未登入使用者可在下次密碼重設時升級。
不要要求所有使用者同一天全部改密碼,除非已經發生重大外洩或舊 hash 風險太高。
六、 密碼建立與修改
1. 新密碼要擋常見與外洩密碼
密碼儲存不只發生在登入,也發生在:
- 註冊。
- 修改密碼。
- 忘記密碼重設。
- 管理員重設密碼。
建立密碼時應檢查:
- 長度是否足夠。
- 是否在常見密碼清單。
- 是否已知外洩。
- 是否和帳號資訊太相似。
- 是否符合產品風險等級。
NIST SP 800-63B 建議在建立或修改密碼時,將候選密碼與常見、預期或已外洩密碼清單比對。
2. 不要過度依賴複雜度規則
傳統規則常見:
至少一個大寫
至少一個小寫
至少一個數字
至少一個符號
每 90 天強制更換這些規則可能讓使用者創造可預測密碼:
Password1!
Summer2026!更好的方向:
- 鼓勵長密碼。
- 允許密碼管理器貼上。
- 擋常見與外洩密碼。
- 提供密碼強度提示。
- 有外洩或可疑活動時才要求更換。
- 鼓勵 MFA 或 Passkey。
具體政策仍要看合規要求與產品場景。
3. 修改密碼要撤銷舊登入狀態
使用者修改密碼後,通常要:
- 更新 password hash。
- 更新 passwordChangedAt。
- 撤銷其他裝置 session。
- 撤銷 refresh token。
- 發送安全通知。
- 寫 audit log。
如果密碼被盜,攻擊者可能已經登入。只改 hash 不撤銷 session,風險還在。
七、 資料庫設計
1. 不要把密碼欄位放進普通 user DTO
可以分離:
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 抽成獨立概念:
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 可以記:
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 不是讓外洩無害,而是爭取時間:
讓攻擊者破解變慢
讓團隊有時間撤銷 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 放資料庫。
介面可以這樣設計:
type HashResult = {
encodedHash: string
}
type PasswordHasher = {
hash(password: string): Promise<HashResult>
verify(password: string, encodedHash: string): Promise<boolean>
needsRehash(encodedHash: string): boolean
}使用:
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。
題目延伸:修改密碼流程
修改密碼時要做:
驗證目前密碼或完成 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 |
| 成本參數 | 要壓測,並能隨時間升級 |
| 舊 hash | algorithm version + 登入成功後 rehash |
| 外洩應對 | 撤銷 session、通知、重設密碼、監控攻擊 |
一句完整的面試回答可以是:
密碼不能明文儲存,也不應該可逆加密後存,因為系統只需要驗證密碼,不需要還原密碼。正確做法是使用專門的 password hashing 演算法,例如 Argon2id、scrypt、bcrypt 或 PBKDF2,為每個密碼使用唯一 salt,並設定合理成本參數,讓離線猜密碼變得昂貴。Salt 不需要保密,可以和 hash 一起存;pepper 是系統層級秘密,應放在資料庫外,例如 Secret Manager 或 HSM,作為防禦深度。不要用 SHA-256、MD5 或自己發明多層 hash。Hash 結果要保存演算法與參數,方便未來提高成本或遷移演算法;登入成功時可以做 needsRehash。若發生外洩,要根據外洩範圍撤銷 session、輪換秘密、要求重設密碼並通知使用者。