开门见山:一秒钟的工程结论
密码存储必须用 Argon2id(OWASP 最低参数 m=19 MiB, t=2, p=1,实测目标 250ms),绝不用裸 SHA-256、绝不用 bcrypt(除非遗留系统)、绝不做明文/可逆存储;MFA 部署顺序必须是"Passkey(FIDO2)> 硬件密钥 > TOTP > 推送(必须开 Number Matching)> SMS(仅在合规允许时保留,不作为主认证因子)“——SMS 一票否决在关键系统禁用;Passkey 是唯一能抵御 AiTM(中间人钓鱼)的方案,Kroll 研究显示 90% 的被入侵组织在事件发生时已启用 MFA,说明"开了 MFA 不等于安全”,关键在于 MFA 的抗钓鱼等级;企业必须用 Conditional Access 强制"抗钓鱼 MFA"(Authentication Strength),同时堵死 SMS/邮件/QR 码回退路径——一旦保留弱回退,Passkey 的安全性会被降级到最低因子。
如果你赶时间,把上面这段话贴到方案评审文档里就够了。如果你接着往下读,这篇文章会回答几个更本质的问题:为什么 2026 年"我们启用了 MFA"这句话在安全评审中几乎不能作为安全证据?为什么 Microsoft 观察到 AiTM 攻击一年暴涨 146%,但 90% 被入侵的公司都"开了 MFA"?为什么同一套 Passkey,"同步的"和"硬件绑定的"在 NIST AAL 分级里差距巨大?为什么企业部署 Passkey 后,攻击者反而去打"账号找回流程"这个最薄弱的环节?
一、先看清战场:密码时代的三座大山
1.1 Verizon DBIR 的数据:密码仍然是 80% 攻击的起点
FIDO Alliance 用一个数字总结现状:密码导致了 80% 的数据泄露。Verizon 2024 DBIR 进一步细化:22% 的确认泄露事件始于凭据被盗。
三个真实案例(全部来自 SentinelOne 整理):
| MGM Resorts | 2023 | 一次社工电话骗取 IT 帮助台重置 MFA | 1 亿美元 EBITDAR 损失 + 1000 万一次性成本 |
| Colonial Pipeline | 2021 | 被盗账号进入 VPN(未启用 MFA) | 美国东海岸燃油供应中断 |
| Twilio | 2022 | 钓鱼钓鱼员工凭据(Okta AiTM) | 波及下游客户(含 Signal 用户手机号) |
MGM 案例特别值得反复研究:攻击者没有破解任何密码、没有绕过任何 MFA——他们只是打电话给 IT 帮助台,冒充员工让 IT 重置 MFA。这暴露了身份认证体系的真正薄弱点不在认证本身,而在认证的旁路(recovery flow、help desk、账号找回)。这是本文反复出现的主题。
1.2 密码的三宗罪
罪一:可预测性。NIST SP 800-63B-4(2025 更新)明确规定:
- 用户自选密码至少 8 字符;
- 单因子认证场景必须 15 字符以上;
- 系统必须支持至少 64 字符的最大长度(防止用户想用长密码却输不进去);
- 必须对照"泄露密码字典"拒绝已知泄露密码(Have I Been Pwned API);
- 禁止强制"定期换密码"和"组合规则"(要求大小写+数字+特殊字符)——这两个 2003 年 NIST 特殊出版物的规则在 2017 年被 Bill Burr 本人在 NPR 访谈中承认是错的,因为它们驱动用户做出可预测的模式(如 Summer2024! → Summer2025!)。
罪二:可重用性。用户平均在 100+ 网站用同一套密码,一次外网论坛泄露 = 企业 VPN 沦陷(Credential Stuffing 攻击)。
罪三:可钓鱼。无论密码多强,只要用户在钓鱼站输入一次就完蛋——密码是"可钓鱼凭据"。
1.3 密码存储的唯一正确做法:Argon2id
OWASP Password Storage Cheat Sheet 明确规定:
推荐参数(OWASP 最低要求):
Argon2id with m=19 MiB (19456 KiB), t=2, p=1
更保守配置(服务器允许时):
Argon2id with m=46 MiB, t=1, p=1
设计目标:单次哈希 ≈ 250ms(防止暴力破解但用户可接受)
Python 实战代码:
# pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
# ============ OWASP 最低推荐参数 ============
ph = PasswordHasher(
time_cost=2, # t = 2 iterations
memory_cost=19456, # m = 19 MiB = 19456 KiB
parallelism=1, # p = 1 lane
hash_len=32, # 输出 32 字节
salt_len=16, # 16 字节盐
)
# 注册时
password = "CorrectHorseBatteryStaple123!"
hashed = ph.hash(password)
# 输出形如: $argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$…
# 登录时
def verify_login(stored_hash: str, input_password: str) –> bool:
try:
# verify 会自动 rehash if 参数升级
return ph.verify(stored_hash, input_password)
except VerifyMismatchError:
return False
except InvalidHashError:
# 数据库中存储的 hash 格式损坏,告警
raise
绝对禁止:
# ❌ 禁止 1: 裸 SHA-256 (可被 GPU 每秒 10^10 次暴力)
import hashlib
hashlib.sha256(password.encode()).hexdigest()
# ❌ 禁止 2: SHA-256 + 盐(仍然 GPU 可暴力,只是防彩虹表)
hashlib.sha256(salt + password.encode()).hexdigest()
# ❌ 禁止 3: MD5 / SHA-1(已破解)
# ❌ 禁止 4: 自己发明"加密"(AES 加密密码 = 等价明文存储)
# ❌ 禁止 5: bcrypt with cost < 10(2026 年应 ≥ 12)
为什么不是 bcrypt:bcrypt 只能调 cost factor(迭代次数),无法调内存——GPU 和 ASIC 对 bcrypt 的并行化效率远高于 Argon2id。bcrypt 在 2026 年仅适用于遗留系统迁移期。
二、MFA:从"必选项"到"分级体系"
2.1 MFA 因子分级:不是所有 MFA 都平等
这是 2026 年身份认证最重要的认知转变——"启用 MFA"不再是一个二值标志,而是一个从"几乎无用"到"几乎完美"的连续光谱:
| FIDO2 Passkey / 硬件密钥 | 抗钓鱼 | ✅ 抵御 | ✅ 无此问题 | 首选 |
| Windows Hello for Business | 抗钓鱼 | ✅ | ✅ | 企业首选 |
| 智能卡 / PIV | 抗钓鱼 | ✅ | ✅ | 政府/高合规 |
| TOTP(Google Authenticator 等) | 弱抗钓鱼 | ⚠️ 可被中继 | ✅ | 过渡期 |
| Push 推送 + Number Matching | 弱 | ❌ 可被中继 | ⚠️ 需 Number Match | 仅配合 CA 使用 |
| Push 推送(无 Number Match) | 弱 | ❌ | ❌ 疲劳攻击重灾区 | 不推荐 |
| SMS / 语音 OTP | 极弱 | ❌ | ⚠️ | 关键系统禁用 |
| 邮件 OTP | 极弱 | ❌ | ⚠️ | 禁用 |
NIST SP 800-63B-4 的 AAL(Authenticator Assurance Level)分级:
- AAL1:单因子(密码)
- AAL2:双因子(密码 + TOTP / Push)
- AAL3:硬件-backed、抗钓鱼(YubiKey 设备绑定 Passkey、PIV 智能卡)
Yubico 明确指出:“设备绑定的 Passkey(YubiKey)是唯一满足 AAL3 的方案”。同步的 Passkey(iCloud Keychain、Google Password Manager)不满足 AAL3——因为私钥可以在云端同步,攻击者一旦攻破云端账号即可获取所有 Passkey。
2.2 MFA 疲劳攻击:Push 通知的阿喀琉斯之踵
MFA Fatigue(也叫 Push Bombing) 是 2022-2026 年最主流的 MFA 绕过手法:
攻击流程:
1. 攻击者已从暗网/钓鱼拿到受害者的用户名+密码
2. 攻击者开始登录,触发 MFA 推送
3. 受害者手机收到推送:"是否允许登录?[允许] [拒绝]"
4. 攻击者连续触发 30-50 次推送(凌晨 2 点最有效)
5. 受害者被骚扰到崩溃,误点"允许"
6. 攻击者成功登录
典型案例:Uber 2022、Cisco 2022、Twilio 2022
全部通过 MFA 疲劳 + 社工完成
Microsoft 的回应:Number Matching 强制化
Microsoft Entra ID 在 2023 年 5 月开始默认启用 Number Matching,2026 年已强制所有 Authenticator 推送必须带数字匹配:
受害者手机收到:
"你正在尝试登录 Microsoft Azure。
输入你在登录屏幕上看到的数字:[ _ ]"
登录屏幕显示的数字:47
受害者必须在手机输入 "47" 才能批准
攻击者无法看到登录屏幕上的数字 → 推送轰炸彻底失效
企业必须做的配置:
# Microsoft Graph PowerShell 强制 Number Matching
Update-MgPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration `
–AuthenticationMethodConfigurationId "MicrosoftAuthenticator" `
–BodyParameter @{
"@odata.type" = "#microsoft.graph.microsoftAuthenticatorAuthenticationMethodConfiguration"
state = "enabled"
includeTargets = @(@{
targetObjectType = "group"
id = "all-users"
authenticationMode = "any"
featureSettings = @{
numberMatchingRequiredState = "enabled" # ← 关键
displayAppInformationRequiredState = "enabled"
displayLocationInformationRequiredState = "enabled"
}
})
}
2.3 AiTM:为什么"开了 MFA 仍然被入侵"
这是 2026 年企业身份安全最核心的认知——Kroll 研究发现:90% 被入侵的组织在事件发生时已启用 MFA。Microsoft 观察:AiTM 攻击一年增长 146%。
AiTM 的工作原理:
【传统钓鱼】
受害者 → 输密码到钓鱼站 → 攻击者拿到密码
受害者开启 MFA → 攻击者拿密码但无法过 MFA → 攻击失败
✅ 传统 MFA 对传统钓鱼有效
【AiTM(Evilginx 2 / Tycoon 2FA / Evilproxy)】
1. 受害者点击钓鱼链接 → 进入 Evilginx 反向代理
2. Evilginx 同时连接真正的 login.microsoft.com
3. 受害者在"假登录页"输入密码 → Evilginx 转发给真微软
4. 微软返回 MFA 挑战 → Evilginx 转发给受害者
5. 受害者完成 MFA(TOTP / Push 都能通过)→ Evilginx 转发回微软
6. 微软返回**有效会话 Cookie** → Evilginx **同时复制一份**
7. 受害者登录成功(无感知)→ 攻击者用偷到的 Cookie 直接登录
8. **绕过了 MFA** —— 因为攻击者不再需要重新认证
关键:Evilginx 是一个反向代理,它让受害者以为自己在跟真微软通信,
同时把"已认证的会话 Cookie"偷走。
为什么 TOTP / Push 在 AiTM 下失效:因为它们验证的是"用户在登录时是否知道共享密钥/拥有手机"——而攻击者并不需要"重新验证",只需要"偷走已验证的会话"。
为什么 Passkey 能抵御 AiTM:这是 Passkey 的根本价值,下一节详细讲。
AiTM 的检测线索(来自 decryptiondigest.com 的实战指南):
// KQL:检测 AiTM 典型行为——登录后 30 分钟内创建邮件转发规则
let SuspiciousSignIns = SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == "0" // 成功登录
| where RiskState != "none" or RiskLevelAggregated != "none"
| project UserPrincipalName, SignInTime = TimeGenerated, SignInIP = IPAddress;
OfficeActivity
| where TimeGenerated > ago(24h)
| where Operation in ("New-InboxRule", "Set-InboxRule", "UpdateInboxRules")
| where Parameters has_any ("ForwardTo", "RedirectTo", "ForwardAsAttachmentTo")
| join kind=inner SuspiciousSignIns on $left.UserId == $right.UserPrincipalName
| where abs(datetime_diff('minute', TimeGenerated, SignInTime)) < 30
| project TimeGenerated, UserPrincipalName, Operation, Parameters
关键洞察:攻击者拿到会话后通常在 30 秒-几分钟内执行"收尾动作"(创建邮件转发规则、偷邮箱、注册自己的 MFA 设备、篡改 OAuth 授权)——这些"登录后行为"是最可检测的信号,比"登录前行为"更可靠。
2.4 Proofpoint 发现:FIDO 的"降级攻击"
即使部署了 FIDO Passkey,攻击者仍有一条路径:FIDO 降级攻击。
攻击流程:
1. 用户访问的站点支持 Passkey 也支持密码
2. 攻击者用专门的 "phishlet" 修改登录流程
3. 让受害者的浏览器认为"该站点不支持 Passkey"
4. 受害者降级到密码+MFA 登录 → AiTM 偷会话
微软的应对:Conditional Access 的 Authentication Strengths
—— 强制"必须抗钓鱼 MFA",不给降级机会
企业教训:Passkey 的安全性 = Passkey 本身 + 回退路径的强度。如果你部署了 Passkey 但保留了 SMS 回退,攻击者直接走 SMS 路径——整个体系被降级到 SMS 的安全水平。
三、Passkey:为什么它是唯一抗 AiTM 的方案
3.1 WebAuthn / FIDO2 的密码学原理
Passkey 基于 FIDO2 标准,具体由两个协议组成:
- WebAuthn(W3C):浏览器与服务器之间的 API;
- CTAP(FIDO Alliance):浏览器/操作系统与认证器(手机/YubiKey/TPM)之间的协议。
核心密码学设计:
【注册阶段】
1. 服务器生成 challenge(随机 32 字节)+ rpId(如 example.com)
2. 浏览器调用 navigator.credentials.create() → 认证器
3. 认证器生成新密钥对 (sk, pk) —— 私钥永不出认证器
4. 认证器对 {challenge, rpId, origin, 公钥, 凭据ID} 签名
5. 服务器存储 {credentialId, 公钥, 签名计数器, 用户ID}
【认证阶段】
1. 服务器生成新 challenge + rpId
2. 浏览器调用 navigator.credentials.get() → 认证器
3. 认证器用 sk 对 {challenge, rpId, origin, counter} 签名
4. 服务器验证:签名用存储的公钥可验证、challenge 匹配、
rpId 与自己的域名匹配、origin 与自己的域名匹配、
counter 单调递增
5. 通过 → 登录成功
3.2 为什么 Passkey 天然抗钓鱼:Origin Binding
这是 Passkey 区别于所有其他 MFA 的根本机制:
密码:用户输入字符串 → 任何拿到字符串的人都能在任何地方用
→ 攻击者钓鱼拿到密码,在自己服务器上重放即可
TOTP:基于共享密钥 + 时间窗口 → 密钥本身是"可移植秘密"
→ 攻击者钓鱼拿到 OTP 后 30 秒内转发可用(real-time relay)
Passkey:签名绑定 {challenge, rpId, origin}
→ rpId 是域名(如 example.com)
→ 浏览器只会在 origin 严格匹配 rpId 时才允许签名
→ 攻击者在 evil.com 搭建反向代理,浏览器在 evil.com 的
origin 下无法使用 example.com 的 Passkey
→ **签名操作根本不会发生,攻击者拿不到任何可用的凭据**
重要细节:rpId 与子域名的坑:
// 正确:rpId 必须是"可注册域名后缀",不能是任意子域
const rp = { id: "example.com", name: "Example" };
// ✅example.com、www.example.com、api.example.com 都可用
// ❌ evil.example.com.attacker.com 不可用(因为 rpId 不匹配)
// ❌ sub.example.com 不能作为 rpId(不能覆盖父域)
// 错误示例:允许任何子域
// 若服务器错误地接受 attacker.example.com 作为 rpId,
// 攻击者可以注册自己的 example.com 子域 → 绕过 origin 检查
3.3 同步 Passkey vs 设备绑定 Passkey
这是 2026 年最容易被忽略、但影响最大的 Passkey 设计决策:
| 私钥存储 | 云端加密同步(端到端加密) | 单一硬件,永不出 |
| 跨设备使用 | ✅ 自动同步到所有登录的设备 | ❌ 只能插/靠近特定硬件 |
| 账号恢复 | 依赖云账号安全(Apple ID / Google 账号) | 硬件丢失 = Passkey 丢失(需预先注册多个) |
| 便利性 | ✅ 极高 | ⚠️ 需携带硬件 |
| AAL 分级 | AAL2 | AAL3(仅设备绑定满足) |
| 适用场景 | 消费者、内部员工、B2C | 金融、政府、企业 IT 管理员、Break Glass |
关键权衡:同步 Passkey 的安全性 = 云账号的安全性。如果攻击者攻破用户的 Apple ID(比如通过社工重置),就能拿到所有 Passkey——这让同步 Passkey 的抗钓鱼能力受限于"云账号的找回流程"。
企业最佳实践:对所有员工提供同步 Passkey(日常),对高权限账号(管理员、CFO、金融)强制设备绑定 Passkey(YubiKey)。
3.4 PyWebAuthn 服务端代码实战
Duo Security 开源的 py_webauthn 是 Python 生态最主流的 WebAuthn 库:
# pip install py_webauthn
from flask import Flask, request, session, jsonify
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json,
)
from webauthn.helpers.structs import (
PublicKeyCredentialDescriptor,
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
ResidentKeyRequirement,
)
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
import json
app = Flask(__name__)
app.secret_key = "replace-with-crypto-random"
# ============ 内存存储(生产用数据库) ============
users_db = {} # username → {id, credentials: […]}
credentials_db = {} # credential_id → {public_key, sign_count, user_id}
RP_ID = "example.com"
RP_NAME = "Example Corp"
ORIGIN = "https://example.com"
# ============ 注册:生成 options ============
@app.route("/api/register/begin", methods=["POST"])
def register_begin():
username = request.json["username"]
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=username.encode(), # 生产应是不变 user UUID
user_name=username,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED, # 发现式凭据
user_verification=UserVerificationRequirement.REQUIRED,
),
# 支持 ES256 (ECDSA P-256) 和 RS256
# 部分场景加 ED256(EdDSA)
)
# 把 challenge 存到 session,防重放
session["register_challenge"] = bytes_to_base64url(options.challenge)
return options_to_json(options)
# ============ 注册:验证响应 ============
@app.route("/api/register/verify", methods=["POST"])
def register_verify():
username = request.json["username"]
credential = request.json["credential"]
expected_challenge = base64url_to_bytes(session["register_challenge"])
try:
verification = verify_registration_response(
credential=credential,
expected_challenge=expected_challenge,
expected_origin=ORIGIN, # ← origin 严格匹配
expected_rp_id=RP_ID, # ← rpId 严格匹配
require_user_verification=True,
)
except Exception as e:
return jsonify({"error": str(e)}), 400
# 存储 credential
credential_id = bytes_to_base64url(verification.credential_id)
credentials_db[credential_id] = {
"public_key": verification.credential_public_key,
"sign_count": verification.sign_count,
"user_id": username,
"transports": credential.get("transports", []),
}
return jsonify({"verified": True})
# ============ 登录:生成 options ============
@app.route("/api/auth/begin", methods=["POST"])
def auth_begin():
username = request.json.get("username") # 可选:discoverable credentials 可不传
# 找到该用户的所有 credentials
allowed = []
if username:
allowed = [
PublicKeyCredentialDescriptor(id=base64url_to_bytes(cid))
for cid, cred in credentials_db.items()
if cred["user_id"] == username
]
options = generate_authentication_options(
rp_id=RP_ID,
allow_credentials=allowed,
user_verification=UserVerificationRequirement.REQUIRED,
)
session["auth_challenge"] = bytes_to_base64url(options.challenge)
return options_to_json(options)
# ============ 登录:验证响应 ============
@app.route("/api/auth/verify", methods=["POST"])
def auth_verify():
credential = request.json["credential"]
expected_challenge = base64url_to_bytes(session["auth_challenge"])
credential_id = credential["id"]
stored = credentials_db.get(credential_id)
if not stored:
return jsonify({"error": "unknown credential"}), 401
try:
verification = verify_authentication_response(
credential=credential,
expected_challenge=expected_challenge,
expected_origin=ORIGIN,
expected_rp_id=RP_ID,
credential_public_key=stored["public_key"],
credential_current_sign_count=stored["sign_count"],
require_user_verification=True,
)
except Exception as e:
return jsonify({"error": str(e)}), 400
# **关键:检查 sign_count 单调递增(克隆检测)**
if verification.new_sign_count <= stored["sign_count"] \\
and verification.new_sign_count != 0:
# 可能是密钥克隆攻击
return jsonify({"error": "cloned authenticator"}), 401
credentials_db[credential_id]["sign_count"] = verification.new_sign_count
session["user"] = stored["user_id"]
return jsonify({"verified": True})
前端浏览器调用:
// ============ 注册 ============
const registerOptions = await fetch('/api/register/begin', {
method: 'POST',
body: JSON.stringify({ username })
}).then(r => r.json());
// 浏览器调用认证器(Touch ID / Windows Hello / YubiKey)
const credential = await SimpleWebAuthnBrowser.startRegistration(registerOptions);
await fetch('/api/register/verify', {
method: 'POST',
body: JSON.stringify({ username, credential })
});
// ============ 登录 ============
const authOptions = await fetch('/api/auth/begin', { method: 'POST' }).then(r => r.json());
const assertion = await SimpleWebAuthnBrowser.startAuthentication(authOptions);
await fetch('/api/auth/verify', {
method: 'POST',
body: JSON.stringify({ credential: assertion })
});
四、三者对比总表
| 抗钓鱼 | ❌ 完全可钓鱼 | ⚠️ 可被 AiTM 中继 | ✅ Origin binding 抗钓鱼 |
| 抗 AiTM | ❌ | ❌ | ✅ 唯一有效 |
| 抗 MFA 疲劳 | N/A | ❌(除非 Number Match) | ✅ |
| 凭据可移植 | ✅ 字符串可复制 | ⚠️ TOTP 种子可导出 | ❌ 私钥不出认证器 |
| 用户体验 | 差(记忆负担) | 中(需拿手机) | 好(93% vs 75% 成功率) |
| 恢复难度 | 中(重置密码) | 中(重置 MFA) | 难(需重新注册) |
| AAL 分级 | AAL1 | AAL2 | AAL3(仅设备绑定) |
| 企业部署成本 | 低 | 中 | 中高 |
| 2026 年 FIDO 数据 | – | – | 50 亿个 Passkey 在用 |
Microsoft 数据:Passkey 完成 93% 的登录尝试,密码约 75%——这说明 Passkey 不仅更安全,用户体验实际上比密码更好。
FIDO Alliance 2026 World Passkey Day 数据:
- 全球 50 亿个 Passkey 在用;
- 90% 用户知晓 Passkey(大幅提升);
- 75% 用户在至少一个账号启用了 Passkey;
- 49% 用户在有 Passkey 的网站会主动使用;
- 68% 企业已部署或正在部署 Passkey;
- 82% 企业认为完全无密码是终极目标。
PanicVault 的冷数据:50-60% 的网站支持 Passkey,但只有 2-3% 的合格用户创建了 Passkey——支持≠使用。MojoAuth 分析了原因:没有 UX 优化的自助式 Passkey 部署,通常只能达到 5-10% 的采用率——这是企业 Passkey 落地最常见的失败模式。
五、企业落地:Conditional Access 与回退链设计
5.1 Microsoft Entra ID:Authentication Strengths
Entra ID 的 Authentication Strengths 是 2023 年推出的特性,2026 年已成为企业强制抗钓鱼 MFA 的标准做法:
【Authentication Strength 配置】
1. Phishing-resistant MFA(预置策略):
– Windows Hello for Business
– FIDO2 security keys
– Certificate-based authentication (CBA)
2. MFA(预置策略):
– 包含 TOTP、Push、Voice
3. Passwordless MFA(预置策略):
– Windows Hello、FIDO2、Passkey(Device-bound / Synced)
【Conditional Access Policy 示例】
– Target: 所有访问 Exchange Online 的用户
– Grant: Require authentication strength = Phishing-resistant MFA
– Effect: 用户登录时,若使用 TOTP/Push → 被拒绝
若使用 Passkey/YubiKey → 放行
实战注意事项(来自 Jan Bakker 的博客):
当 Conditional Access 强制"Passkey(Authentication Strength)“时,如果用户没有 Passkey,会被丢进"interrupt wizard”——用户必须先注册 Passkey 才能继续。如果用户无法注册(比如 TPM 故障、浏览器不支持),就会被锁死。企业必须先做注册引导**,再启用强制策略。**
分阶段部署模板(来自 Nate Hutchinson 的指南):
Phase 1(第 1 个月):注册引导
– 对 Pilot Group 开放 Passkey 注册(不强制)
– 在登录页加"建议注册 Passkey"提示
– 目标:Pilot 组 50% 注册率
Phase 2(第 2-3 个月):组策略
– 对 Pilot Group 启用"phishing-resistant MFA required"
– 监控失败登录和帮助台工单
– 目标:工单不爆、用户不反弹
Phase 3(第 3-6 个月):全公司推广
– 按"部门 → 全员"节奏推
– 对管理员账号提前一轮
– 保留 Break Glass 账号(仅硬件密钥)
Phase 4(第 6 个月+):清理弱因子
– 禁用 SMS OTP
– 禁用 Voice Call OTP
– TOTP 仅作为紧急回退(需 CA 策略限定场景)
5.2 回退链设计:Passkey 的"阿喀琉斯之踵"
WorkOS 的警告一针见血:
“Passkey 停止钓鱼,但你的 MFA 回退路径撤销了这一切”(Passkeys stop phishing. Your MFA fallbacks undo it.)
典型错误:
✗ 错误回退链设计:
Passkey → 用户忘了 → "发送 SMS 验证码到手机"
→ "发送重置链接到邮箱"
→ "回答安全问题"
攻击者视角:根本不需要攻破 Passkey
只要 SIM Swap 拿到手机号 /
通过社工重置邮箱密码
→ 完全绕过 Passkey
正确回退链设计:
✓ 推荐回退链(按优先级):
1. 第二个 Passkey(用户预注册两个)
2. 硬件安全密钥(YubiKey 备份)
3. 恢复代码(一次性 8-10 位,用户注册时下载保存)
4. (高风险账号)身份验证员人工审核
✗ 禁止作为 Passkey 的回退:
– SMS OTP
– 邮箱重置链接(除非邮箱本身用 Passkey 保护)
– 安全问题(NIST 2017 年已明确禁止)
– "Draw a pattern" 等替代解锁方式
Microsoft 2026 年的呼吁:
“真正的进步不仅是增加更强的登录方式,而是移除可钓鱼的凭据并加强常见的攻击路径如恢复流程。”
5.3 Okta / Google Workspace 的对应策略
Okta:
- Authentication Policy 按 App 设置"Allowed Authenticator Methods";
- Profile Enrollment 强制新用户注册 Passkey;
- FastPass(Okta 的设备绑定 Passkey 实现);
- Passwordless + phishing-resistant Policy 模板。
Google Workspace: - Context-Aware Access 强制 Passkey;
- Advanced Protection Program(仅 YubiKey/Titan Key);
- 2SV Enrollment Group 强制分组启用。
六、FAQ:最常见的 5 个问题
Q1:我们已经强制 MFA,是不是就安全了?
不是。Kroll 的研究:90% 被入侵组织已启用 MFA。关键不是"是否启用",而是"启用的 MFA 是什么因子"——SMS/Push 可以被 AiTM 完全绕过,TOTP 可以被实时中继。只有 Passkey/硬件密钥是真正抗 AiTM 的。
Q2:Passkey 丢了怎么办?账号就找不回来了?
这是同步 Passkey 最大的优势——iCloud Keychain 和 Google Password Manager 会把 Passkey 同步到所有设备,丢一部手机通常不影响登录。设备绑定 Passkey(YubiKey)确实会丢,所以企业要求注册两个硬件密钥,一个日常用,一个放保险柜。绝对不能用"SMS 重置"作为 Passkey 丢失的恢复方式——那等于重新打开攻击面。
Q3:公司已经买了 RSA SecurID/Duo,是不是要全部推翻?
不用推翻,分阶段。第一阶段:保留现有 MFA,同时给 Pilot Group 开放 Passkey 注册。第二阶段:对管理员账号强制 Passkey。第三阶段:Conditional Access 强制 phishing-resistant MFA——老 MFA 被自然淘汰。不要一刀切换掉,会产生大量帮助台工单。
Q4:Argon2id 参数怎么调?"
OWASP 最低要求:m=19 MiB, t=2, p=1,目标单次 250ms。服务器内存充足时:m=46 MiB, t=1, p=1(更高内存、更少迭代)。原则:内存参数调到"单机跑满当前硬件但不影响业务"的最大值。GPU/ASIC 攻击者可以在普通 CPU 上大规模并行,所以内存参数是唯一能拉开硬件差距的杠杆**。
Q5:怎么说服老板投入 Passkey?
用三组数字:
七、结语:身份认证的终极形态是"没有密码"
写到这里,你可能已经看到本文反复出现的主题——
身份认证的演进史,本质是一部"攻击面收窄史":
- 密码时代:攻击面 = “字符串能否被猜/偷/钓鱼”(极宽);
- MFA 时代:攻击面 = “第二个因子能否被绕过”(Push/SMS 仍可,TOTP 部分可);
- Passkey 时代:攻击面 = “Origin binding 能否被欺骗 + 云账号能否被攻破”(极窄)。
但 Passkey 不是终点,而是"把攻击面转移到更难打的地方"——从"钓鱼网站"转移到"云账号恢复流程"(iCloud/Google 账号被社工重置的风险)和"硬件物理安全"(YubiKey 被盗)。FIDO Alliance 2026 报告里 50 亿 Passkey 的背后,是 50 亿个"云账号被渗透后 Passkey 全部沦陷"的潜在风险点。
MGM 案例给我们的最深教训:真正安全的认证体系,不是最强的认证因子,而是最不容易被旁路的体系。Passkey 再强,如果 IT 帮助台可以"电话验证后重置 MFA",攻击者仍然可以社工重置——认证体系的安全 = 认证因子 × 回退流程的强度。
密码学工程的第一铁律:不要相信"我们上了 Passkey 所以安全"。问自己三个问题:


