身份系统是在线业务的底座。它跟数据库一样,承载核心状态(用户凭据、会话、权限),但又比数据库更难迁移——因为它是一个”活”的系统,随时在接收登录请求,任何一次写入失败、一次签名不匹配、一次 cookie domain 不对,都会立刻被用户感知为”登不上”。
身份迁移不是一次 DB migration,而是一个需要数月、跨多个团队协作、必须带灰度与回滚的工程。本文把笔者在几次迁移中积累的教训梳理成一套可执行的方法论,覆盖动因、策略、密码与 Token 迁移、多 IdP 合并、事故案例、响应 Runbook。
一、为什么要迁移:动因分类
迁移从来不是为了迁移本身。每一次迁移都有明确的业务/技术触发点,搞清楚动因对后续决策至关重要——不同动因对应不同的风险容忍度和时间盒。
1.1 legacy → 现代:老旧 LDAP + 定制认证栈
最常见的场景。企业内部或早期 SaaS 产品,最初用 OpenLDAP/Active Directory + 自己写的认证服务,凭据验证逻辑散落在各个应用里。十年后出现以下问题:
- MFA 支持差(没有原生 TOTP/WebAuthn,要自己实现一遍)
- 协议不现代(只支持 LDAP Bind,不支持 OIDC/SAML,接入新 SaaS 要写适配层)
- 密码策略无法细粒度配置
- 没有审计日志标准格式
- 原作者已离职
此时迁移到 Keycloak、Auth0、Okta 这类现代 IdP,是一次”技术债一次性还清”的机会。风险在于:老系统承载的是多年沉淀的用户数据与业务语义,迁移过程中一旦丢失或错乱,代价极大。
1.2 自建 → 托管:维护成本高
团队原本自建了一套 IdP,随着业务变化:
- 安全团队需要做渗透测试、合规审计,自建系统没人维护
- MFA/SSO/社交登录每新增一个供应商就要写一次集成
- 用户量增长后,性能调优、高可用、跨区域部署都变成专职工作
迁移到 Auth0/Okta/AWS Cognito 等托管服务,把非差异化的工作外包。核心判断是”认证是否是业务差异化”。详见 平台选型:Buy vs Build。
1.3 托管 → 自建:vendor lock-in 与价格
反向迁移也很常见。触发点一般是:
- Auth0 / Okta 按 MAU 计费,用户规模增长后年费到七位数,自建团队比账单便宜
- 需要定制功能(特殊的 MFA 流程、合规要求、特殊的身份映射逻辑)托管服务不支持
- 出现数据主权要求,必须把数据放在自己机房
- 供应商安全事件(如 2022 年 Okta 的 LAPSUS$ 事件)动摇了信任
典型迁移目标是 Keycloak。这条路的难点是”你以为用到了 10% 的功能,实际到了开始迁移才发现依赖了 80%“。
1.4 合并收购:两套身份系统并存
公司 A 收购公司 B,A 用 Okta,B 用 Auth0。同一个员工在两边都有账号,同一个外部客户可能也在两边都注册过。
合并的第一个月往往是”双 IdP 共存 + SSO 单向信任”过渡态;后续要做的事:
- 决定”主 IdP”(通常是收购方的)
- 用户身份映射与合并(见第五章)
- 两侧应用的认证端点逐步迁移到主 IdP
- 存在冲突账号的人工处理流程
1.5 合规驱动:出海/进入等保
出海进欧盟(GDPR)、进中国大陆(网络安全法/个保法/数据出境评估)、金融合规(等保三级)等场景,要求:
- 中国用户身份数据不得出境,需要部署独立 IdP 实例
- 不同司法辖区的用户必须隔离存储
- 审计日志留存周期与访问控制要求不同
此时的迁移不一定是”全量替换”,而是”按地域拆分”。这是一种水平切分迁移,本文后续策略章节也适用。
二、迁移策略对比
核心策略有三种,下面详细比较。
2.1 Big-bang:一次性切换
在某个维护窗口(通常是业务低峰、周末凌晨),把所有流量从旧 IdP 切到新 IdP,旧 IdP 下线。
T-7d: 冻结旧 IdP 的配置变更
T-1d: 数据快照 + 校验
T0: DNS/LB 切换
T+1h: 烟囱测试,观察成功率
T+24h: 正式下线旧 IdP
优点:
- 简单。没有双轨运维成本,没有身份映射表要维护
- 时间短。一次性完成,不拖尾巴
- 成本低。不需要写 shadow 迁移代码
缺点:
- 风险集中。切换那一刻出问题就是 P0
- 回滚窗口短。一旦新 IdP 发行了 token,回滚旧 IdP 也不认这些 token
- 对用户体感差。很多场景下需要用户强制重新登录一次
适用场景:
- 内部系统,用户量几百到几千
- 用户接受维护窗口停机
- 旧系统的数据质量很差,反正也要清洗重来
2.2 Coexistence / Lazy Migration:双轨并行 + 懒惰迁移
旧系统继续运行,新系统承接新用户注册。存量用户按需迁移:用户某次登录时触发迁移(“lazy migration”),迁移后再也不访问旧系统。
用户登录请求
│
▼
身份路由(Router)
┌───────┴───────┐
▼ ▼
新 IdP 存在? 在旧 IdP 验证
│ │成功
│ ▼
│ shadow 写入新 IdP
│ │
└───────┬───────┘
▼
颁发 token
优点:
- 无停机。用户几乎无感
- 风险分散。某个用户迁移失败只影响他一人
- 回滚容易。旧系统还在线
缺点:
- 双系统运维成本。两套监控、两套值班、两套告警
- 长尾。可能 1 年后还有 5% 用户没迁移(年付用户、休眠账号)
- 复杂度高。需要写路由、shadow 同步、一致性校验
适用场景:
- 用户量百万级以上
- 不接受停机
- 有足够工程资源支撑 6~12 个月长周期
2.3 Parallel Run:流量复制
新旧系统同时接收登录请求,但只有一个的响应真正返回给用户;另一套作为”影子”对比结果,用于验证新系统行为等价。
def login(username, password):
old_result = old_idp.authenticate(username, password)
try:
new_result = new_idp.authenticate(username, password)
if not equivalent(old_result, new_result):
metrics.incr("login.parallel_diff",
tags=[f"reason={diff_reason(old_result, new_result)}"])
log_diff(username, old_result, new_result)
except Exception as e:
metrics.incr("login.shadow_error")
return old_result # 仍然以旧系统为准优点:
- 零风险验证。新系统的 bug 不会影响用户
- 能跑真实流量。比任何 staging 环境都真实
- 指标可量化。可以看到等价率多少,哪些账号有差异
缺点:
- 不会最终完成迁移。Parallel run 只是验证手段,还需要搭配其他策略收尾
- 双倍调用成本。DB 连接、CPU、外部依赖都加倍
2.4 渐进式切流:按 userID 哈希灰度
在 parallel run 建立信心后,按用户 ID 哈希分批切真流量到新系统:
def pick_idp(user_id: str) -> str:
bucket = int(hashlib.sha256(user_id.encode()).hexdigest(), 16) % 100
if bucket < rollout_percent: # rollout_percent: 1 → 5 → 20 → 50 → 100
return "new"
return "old"关键实践:
- 同一用户稳定命中同一侧(hash userID,不要用 random 或 timestamp)
- 监控卡口:成功率 < 99.5% 自动回滚 rollout_percent
- 每一档停留足够长(至少 1 个业务周期,通常 24~72 小时)
三、密码迁移:最难的部分
密码是唯一不能”备份恢复”就搞定的数据——它以单向 hash 存在,新系统往往不支持旧系统的 hash 算法。这一章展开讨论各种方案。
3.1 明文密码:极少数遗留系统
如果旧系统存的是明文密码(是的,2026 年还有这种系统),直接导入新系统时用新算法重新哈希即可。
for user in legacy_db.query("SELECT id, email, password FROM users"):
new_hash = bcrypt.hashpw(user.password.encode(), bcrypt.gensalt(12))
new_idp.create_user(email=user.email, password_hash=new_hash)本文不展开,重点:做完之后务必在旧系统侧彻底清除明文字段,包括备份、日志、oplog。
3.2 Hash 兼容:直接迁移
如果新旧系统都支持相同的算法(例如都支持 bcrypt
$2b$ 格式),直接迁移 hash
字符串即可,用户完全无感:
INSERT INTO new_idp.users (email, password_hash, created_at)
SELECT email, password_hash, created_at FROM legacy.users;Keycloak 支持导入多种 hash(bcrypt、pbkdf2、scrypt)。Auth0 支持通过 Bulk User Import 导入 bcrypt/pbkdf2 hash。
注意 bcrypt 的 cost 参数:如果旧系统用 cost=10,新系统默认 cost=12,不必强制 re-hash,用户下次登录时再按需升级即可。
3.3 Hash 不兼容:三种主流方案
早期系统常用 md5(password + salt) 或
sha1(password),新系统(Keycloak、Auth0)只支持现代算法(bcrypt/pbkdf2/argon2)。
方案一:包装 hash(Nested Hash)
新系统存储
bcrypt(md5(password + old_salt))。登录时由新系统重算整个嵌套:
def verify_nested(password: str, stored_hash: str, old_salt: str) -> bool:
inner = hashlib.md5((password + old_salt).encode()).hexdigest()
return bcrypt.checkpw(inner.encode(), stored_hash.encode())这样可以一次性把所有用户的 hash 在线下迁移完毕,无需等待用户登录。
优点:迁移一次完成,无双轨。
缺点:
- 新系统永远背着一个”旧算法”逻辑,难以清除
- 下次如果想再升级到 argon2,还要套一层
- 多数 SaaS IdP 不支持自定义验证逻辑,这招只对自建系统可行
方案二:Shadow Migration(拦截登录请求)
用户下次登录时,新系统尚无 hash:
1. 用户提交 email + password
2. 新系统查不到该用户的 hash
3. 新系统调用旧系统 verify(email, password)
4. 旧系统返回 OK
5. 新系统用明文 password 生成 bcrypt hash 存入新 DB
6. 下次登录,新系统直接用本地 hash 验证
核心代码框架:
def authenticate(email: str, password: str) -> AuthResult:
user = new_db.find_user(email)
if user and user.password_hash:
if bcrypt.checkpw(password.encode(), user.password_hash):
return AuthResult(success=True, user=user)
return AuthResult(success=False, reason="bad_password")
legacy_ok = legacy_idp.verify(email, password)
if not legacy_ok:
return AuthResult(success=False, reason="bad_password")
new_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(12))
new_db.upsert_user(email=email, password_hash=new_hash,
migrated_at=datetime.utcnow())
metrics.incr("migration.lazy.success")
return AuthResult(success=True, user=new_db.find_user(email))优点:
- 用户完全无感
- 用明文升级 hash,不背遗留算法
缺点:
- 未登录的用户永远不会迁移(长尾)
- 需要旧系统保持在线
- 密码验证端点成为攻击面(必须内部鉴权 + 限流 + 审计)
长尾处理:给 N 个月后仍未登录的用户发密码重置邮件,或强制下线。
方案三:强制重置
给所有用户发邮件”因为安全升级,请点击链接重置密码”。
优点:干净,新系统完全没有遗留。
缺点:
- 用户体验最差,登录漏斗掉 20~40%
- 邮件送达率问题(垃圾邮件、换邮箱)
- 会被当作钓鱼攻击被用户举报
一般只在”前任系统有安全事件泄露了 hash”或”实在没有其他路径”时采用。
3.4 Auth0 的 password_migration Hook
Auth0
为方案二提供了官方机制:Custom Database Connection
+ Login Script。
function login(email, password, callback) {
const request = require("request");
request.post({
url: "https://legacy.example.com/internal/verify",
json: { email, password },
headers: { "X-Internal-Token": configuration.LEGACY_TOKEN }
}, function (err, resp, body) {
if (err) return callback(err);
if (resp.statusCode !== 200) return callback(new WrongUsernameOrPasswordError(email));
callback(null, {
user_id: body.user_id,
email: body.email,
email_verified: body.email_verified
});
});
}Auth0 首次验证成功后,会自动把用户写入 Auth0 自身的用户库,之后不再回调旧系统。这就是一套开箱即用的 shadow migration。
Keycloak 的 User Storage SPI
提供类似机制。
四、Token 迁移
密码搞定了,Token 是第二道坎。JWT/session cookie 的变更会影响所有已登录会话。
4.1 JWT issuer 变更
迁移前:
{ "iss": "https://auth.old.example.com", "sub": "12345", "exp": 1713456789 }迁移后:
{ "iss": "https://auth.new.example.com", "sub": "12345", "exp": 1713456789 }所有验证 JWT 的服务(API Gateway、微服务中间件)都必须同时信任新旧 issuer 的 JWKS。
4.2 Grace Period:双发/双认期
典型做法是维护 30~90 天的 grace period,验证中间件同时信任两套 JWKS:
TRUSTED_ISSUERS = {
"https://auth.old.example.com": "https://auth.old.example.com/.well-known/jwks.json",
"https://auth.new.example.com": "https://auth.new.example.com/.well-known/jwks.json",
}
def verify_jwt(token: str) -> Claims:
unverified = jwt.decode(token, options={"verify_signature": False})
issuer = unverified.get("iss")
if issuer not in TRUSTED_ISSUERS:
raise InvalidToken("unknown_issuer")
jwks = jwks_cache.get(TRUSTED_ISSUERS[issuer])
claims = jwt.decode(token, jwks, algorithms=["RS256"], issuer=issuer,
audience="api.example.com")
return claimsgrace period 的长度由 token TTL 决定。如果 access token TTL=1h、refresh token TTL=30d,则至少留 30d + 一周缓冲。
关于 JWT 与 JWKS 的更多细节见 JWT 与 JWKS 密钥轮换。
4.3 客户端更新节奏(移动 App)
Web 前端好办,刷新页面就拿到新版。移动 App 最难:用户可能几个月不更新 App。
关键原则:App 发版时间线必须早于 grace period 结束。
T-60d: App 新版发布(支持同时处理新旧 IdP 返回格式)
T-30d: 新 IdP 开始发 token
T+0: 旧 IdP 停发新 token(grace 开始)
T+30d: 强制升级门槛:老于某版本的 App 启动时弹窗要求升级
T+60d: 旧 IdP JWKS 下线(grace 结束)
“强制升级”必须配合 grace period,否则老版 App 持有的 token 会被拒绝,用户看到的是登录页反复跳转。
4.4 Refresh Token 迁移
Refresh token 不在客户端可见 JWT 里,而是在 IdP 服务端的 session 表里。新旧两套 IdP 不会共享这张表。
处理方法:
- 让所有用户重新登录一次。手段是让 refresh token 验证在迁移日全部失败,触发客户端重新走登录流程
- 或者在迁移日把旧系统的 refresh token 导入新系统(如果协议兼容)
笔者的经验是:接受一次全站重新登录比搞复杂的 refresh token 同步划算得多。用户每几个月被迫登录一次是可以接受的,但同步逻辑 bug 会让你赔上几周。
吊销相关可参考 Session 吊销设计。
4.5 Session Cookie 与 domain 变更
如果登录入口的域名变了(从
auth.old.example.com 迁到
auth.new.example.com),session cookie 的
domain 属性变了,浏览器就不会把旧 cookie 送给新系统:
Set-Cookie: sid=abc; Domain=.old.example.com; HttpOnly; Secure
浏览器不会把这个 cookie 发给
new.example.com。此时唯一合理的处理是让用户重新登录,不要试图在客户端做什么
cookie 转换——这会引入 CSRF 风险。
五、多 IdP 合并时的用户身份映射
合并收购场景下,两套 IdP 的用户数据要合成一套。最难的不是技术,是”同一个人在两边账号怎么合并”。
5.1 通过 email 匹配
前提是 email 已验证且唯一。伪代码:
for a in idp_a.users():
b = idp_b.find_by_email(a.email)
if b and b.email_verified and a.email_verified:
merge_candidates.append((a, b))
else:
new_idp.create(a)坑:email 未验证的账号不能参与匹配,否则任何人都能”抢注”他人邮箱获取合并。
5.2 外部 ID mapping 表
维护一张 identity_mapping 表:
CREATE TABLE identity_mapping (
canonical_user_id UUID PRIMARY KEY,
old_idp_a_user_id TEXT,
old_idp_b_user_id TEXT,
merged_at TIMESTAMPTZ,
merge_strategy TEXT -- 'email_match' / 'manual' / 'phone_match'
);下游所有业务系统都通过 canonical_user_id
引用用户;迁移过程中用这张表做翻译。
5.3 合并冲突处理
两个账号合并时,哪一侧的数据留?规则示例:
- 个人资料(头像、昵称):取更近更新时间
- 订单、积分、权限:合并到 canonical 账号
- 密码:强制重置(不能信任任一侧的凭据代表合并后的身份)
- MFA:保留其中一个,另一个失效,通知用户
这些规则必须写在产品文档里并在合并前告知用户,不要悄悄做合并。
5.4 孤儿账号处理
迁移后可能发现:新系统里存在某个账号,但旧系统里没有对应记录。典型原因:
- 双写阶段新系统写成功、旧系统写失败(数据不一致)
- 合并过程中人为误操作
- 新系统被绕过直接创建(写入脚本漏洞)
处置流程:
- 日报扫描 diff,人工审核
- 标记为
orphan=true,暂时不允许登录(或要求二次验证) - 联系用户确认身份后恢复或删除
六、事故案例:工程师视角的真实教训
6.1 全站登出事故
2022 年 Okta 的一次配置误操作曾短时间影响多家公司 SSO,Cloudflare 事后复盘中强调:核心服务必须能在 IdP 不可用时继续运行。
工程上常见的脆弱点:
- 所有请求都走 IdP 做 token introspection(而不是本地验证 JWT 签名),IdP 挂了所有 API 调用都挂
- Admin/SRE 工具本身也依赖 IdP,IdP 挂了连恢复工具都进不去
- 应急通道(break-glass 账号)从未演练
防御:本地 JWT 验证 + JWKS 缓存、至少一个不走 IdP 的 break-glass 路径、定期故障演练。相关讨论见 认证系统架构。
6.2 Login Storm:雷鸣般的登录风暴
事故过程:
- 凌晨 2 点,运维误操作把新 IdP 的 JWT 签名密钥轮换了
- 所有 access token 同时验证失败
- 客户端自动触发 refresh
- 所有 refresh 同时打到 IdP
- IdP DB 连接池耗尽,后续合法请求也打不进去
- 客户端开始疯狂重试,QPS 从 5k 跳到 80k
- 级联到下游服务,雪崩
教训:
- 密钥轮换必须 overlap(旧密钥继续信任 24~72h)
- 客户端必须有指数退避 + 抖动(jitter),不能简单 for 循环重试
- IdP 自身必须有熔断与限流(即使是自己人流量)
- 监控要能区分”用户活跃度正常上升”和”异常重试风暴”
# 客户端重试必须带抖动
def retry_with_backoff(attempt):
base = min(30, 2 ** attempt)
jitter = random.uniform(0, base * 0.5)
time.sleep(base + jitter)6.3 CSRF 配置丢失
迁移到新 IdP 后,OAuth 流程里的 state
parameter
校验逻辑因为配置项命名变了(csrf.state.verify →
oauth.state.required),默认值为
false,上线前没人注意到。
结果:攻击者可以构造一个 OAuth 回调 URL,把受害者”登录”到攻击者的账号(Session Fixation / Cross-Account CSRF),反之亦然。
教训:
- 迁移清单里必须有”安全配置等价性检查”
- 写自动化测试:手动触发一次 state 不匹配的回调,必须拒绝
- OAuth 流程的每一个参数(state/nonce/code_verifier)都要有正向测试与反向测试
6.4 Cookie domain 没改
登录页从 auth.old.example.com 迁到
auth.new.example.com,新登录成功后
Set-Cookie 还写着
Domain=.old.example.com。
浏览器表现:登录接口返回 200,但下一次请求
Cookie header
是空的。用户体感:“登录成功又跳回登录页”。
教训:
Set-Cookie的 Domain / Path / Secure / SameSite 属性必须在迁移检查清单里- 写 E2E 自动化:登录后跳到受保护页,应为 200;一旦是登录页就报警
6.5 logout URL 没改
IdP 侧配置的”允许的 post-logout redirect URI”列表,在新
IdP 上漏了 /logout/success。用户退出后 IdP 返回
redirect_uri_not_allowed,跳到 404 页面。
用户以为”这个系统退出不了”,反复刷新,留下了活 session,反而更危险。
教训:
- 迁移清单必须列出”所有 redirect URI 白名单”
- 包括:登录成功 / 登录失败 / 注销成功 / MFA 注册成功 / 密码重置成功
- 最好写成代码生成而不是手动配置
七、迁移的可观测性
迁移最怕”盲飞”。以下是必须上线的监控。
7.1 登录成功率 dashboard
按维度拆分:
- IdP:old / new
- 认证方式:password / social / mfa / passkey
- 客户端:web / ios / android / api
- 地域:cn / eu / us
如果某一组合的成功率突然下跌,立刻能定位。
sum by (idp, method) (rate(login_attempts_total{result="success"}[5m]))
/
sum by (idp, method) (rate(login_attempts_total[5m]))
7.2 Token 验证失败率
按失败原因拆分,不要只有一个笼统”验证失败”:
expired:正常,用户没刷新invalid_signature:密钥轮换问题unknown_issuer:grace period 结束/客户端老 tokenaudience_mismatch:配置错误clock_skew:服务器时间异常
7.3 渐进式切流的监控卡口
核心指标有清晰阈值:
rollout_guards:
- metric: login_success_rate_new
threshold: 0.995
window: 5m
action: pause_rollout
- metric: token_invalid_signature_rate
threshold: 0.001
window: 5m
action: rollback_to_previous_bucket
- metric: p99_login_latency_ms
threshold: 800
window: 10m
action: pause_rollout卡口要能自动触发,不要等人看 dashboard。
7.4 DB migration 进度追踪
SELECT
COUNT(*) FILTER (WHERE migrated_at IS NOT NULL) AS migrated,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE migrated_at IS NOT NULL) * 1.0 / COUNT(*) AS ratio
FROM users;把这个数字打到 Prometheus,每小时一条,形成趋势图。长尾就能一眼看出来。
八、事故响应 Runbook:身份系统宕机
身份系统挂了跟 DB 挂了一样严重。以下是值班工程师必须提前演练的动作。
8.1 灰度降级:只允许已登录用户
目标:保住已在线的用户体验,不接受新登录。
动作:
- API Gateway 放行所有带合法 JWT 的请求(本地验证签名,不调用 IdP)
- 登录端点返回 503 + 友好页面:“我们正在恢复登录服务,已登录用户不受影响”
- 对近期即将过期的 access token,临时延长 TTL(通过签发一个新 token,仅用 JWT 本地签名,不走 IdP)
8.2 降级登录:关闭 MFA
前提:事先设计好降级路径。
- 接到 IdP SRE “MFA provider 挂了”通知
- 在某个 feature flag 里切换
mfa.required=false - 允许 email + password 直接登录
- 所有降级期间的登录打上
degraded=true标签 - 恢复后强制这些用户下次登录补做 MFA
注意:降级必须是一个事先准备好的预案,不是事故中临时写的代码。临时写的代码 100% 会漏洞百出。
8.3 冻结写入:只允许读
当 IdP 的数据层异常(如主从延迟过大、DB 不可写),立即冻结写操作:
- 禁止新注册
- 禁止密码修改、MFA 变更
- 禁止权限授予/回收
- 允许 token 验证(纯读)
- 允许登录(如果认证链路只读,如 LDAP Bind)
防止脏数据在恢复时与 backlog 冲突。
8.4 回滚策略:DB snapshot vs 双写
- DB snapshot 恢复:简单,但丢失 snapshot 之后的所有变更(包括合法用户注册、密码修改)。适合刚切换不久(几十分钟)的场景
- 双写回滚:迁移期间维护反向同步(新 IdP → 旧 IdP),回滚时切回旧 IdP 数据几乎不丢。适合大规模迁移,但工程成本高
选哪个取决于”新 IdP 在线多久”和”双写链路质量”。
8.5 通讯链路:状态页与客服通知
触发条件必须事先写死在 Runbook 里:
- 登录成功率跌破 95% 持续 5 分钟 → 在状态页标记为
Degraded - 登录成功率跌破 50% 持续 5 分钟 → 状态页
Major Outage+ 客服脚本切换 - 预计恢复时间 > 30 分钟 → 发邮件/推送给活跃用户
关键:状态页必须独立于身份系统。不要把状态页也挂在需要登录才能访问的后台里——身份系统挂了连状态页都上不去。
九、工程坑点
按事故等级排序的高频坑。
9.1 时间同步
JWT exp/nbf 对时钟敏感。IdP
与应用服务器时钟差 5 分钟,就会出现”刚签的 token 被拒为 not
yet valid”。
防御:NTP 强制;JWT 验证允许
clock_skew=60s。
9.2 JWKS 缓存
IdP 在密钥轮换时,应用服务端的 JWKS 缓存如果太激进(24h),会导致新签发的 token 在应用侧验证失败。
建议:缓存 TTL 5~15 分钟;遇到 kid
未知时触发一次强制刷新。
9.3 邮件送达率
密码迁移走强制重置路径时,邮件送达率决定成败。
坑:
- SPF/DKIM 配置在迁移时跟着变(从
auth.old的 MTA 切到auth.new的 MTA) - 突然大批量发件被 Gmail 限速(需要预热 IP)
- 中国用户邮箱系统(QQ、163、outlook.com 中国段)拒收概率高
9.4 移动 App 审核时延
iOS 审核 1~7 天,Android 较快但也有回滚。迁移时间线必须考虑”最慢的 App 版本什么时候能覆盖 95% 用户”。
9.5 联邦登录(Social / SSO)的 client_id 变更
之前在 Google Cloud Console、Apple Developer、企业 SAML
IdP 注册的 redirect_uri 和
client_id 都跟旧 IdP 绑定。迁移时必须:
- 在每一家供应商后台创建新的 OAuth Client
- 或者保留旧 client 并让新 IdP 代理(看供应商是否允许)
- 配置切换与 App 发版配套
9.6 审计日志的连续性
合规要求审计日志保留几年,迁移跨越系统边界后:
- 老日志在旧 IdP 的审计系统
- 新日志在新 IdP
- 中间的关联:
canonical_user_id在两侧的对应
必须有一张映射表,出审计报告时能还原一个人在两侧系统里的所有操作。
9.7 密码策略差异
旧系统允许 6 位密码,新系统最短要求 12 位。迁移时:
- 存量密码按旧策略保留(否则就是强制重置)
- 下次密码修改时必须符合新策略
- 提示文案要说清楚”密码策略已升级”
9.8 第三方 webhook 的签名密钥
很多业务系统订阅 IdP 的 webhook(用户创建、用户删除、权限变更)。webhook 签名密钥变更时,订阅方必须同步更新。
这个清单是最容易漏的,因为很多 webhook 订阅方不在身份团队管辖内。
十、选型建议
给一组判断标准,帮助选择迁移策略。
10.1 用户规模
- < 5000:Big-bang + 维护窗口。快、简单、便宜
- 5k ~ 100k:Big-bang + 零停机(精心准备 + 回滚预案),或者 Coexistence 短周期(1~3 个月)
- > 100k:Coexistence + Lazy Migration + 渐进切流,周期 6~12 个月
10.2 用户活跃度
- 高活跃(DAU/MAU > 50%):Lazy migration 覆盖率高,适合
- 低活跃(DAU/MAU < 10%):Lazy 长尾严重,最终还是要强制重置
10.3 可用性预算
- 允许停机(内部系统、B2B 深度对接):Big-bang
- 不允许停机(C 端、关键 B2B):Coexistence + 渐进切流
10.4 回滚窗口
- 能回滚:双写链路 + snapshot。必须有定期回滚演练
- 不能回滚:必须在切换前做 parallel run + 渐进切流,把风险压到极小
10.5 团队能力
- 有 3+ 专职工程师,支持长周期:Coexistence
- 只有 1 人兼职:Big-bang + 更长的准备期
10.6 决策树摘要
用户量
/ \
< 5k > 5k
| |
Big-bang 可停机?
/ \
是 否
| |
Big-bang Coexistence + Shadow + 渐进切流
10.7 一个务实的清单
迁移前必须完成:
缺一项都别开始。
十一、结语
身份迁移是一场综合考验。考验的不仅是认证协议的理解,还有:数据一致性、系统可观测性、客户端生命周期管理、事故响应纪律、跨部门协作。
记住三条原则:
- 可回滚优先于可前进。迁移的每一步都要能退回上一步
- 可观测优先于可优化。没有指标就是在盲飞,别让优化代替监控
- 演练优先于预案。没演练过的预案不是预案,是幻觉
其他相关阅读:
十二、参考资料
- RFC 6749、OpenID Connect Core 1.0、RFC 7009、RFC 7662
- NIST SP 800-63-3 Digital Identity Guidelines
- Keycloak、Auth0、Okta、Microsoft Entra 官方迁移与会话管理文档
- OWASP ASVS 4.0、OWASP Session Management Cheat Sheet
- 本系列相关文章:JWT、JWS、JWE、JWKS 一次讲透、Session、Refresh Token 与吊销体系、自建还是采购:Keycloak、Auth0、Entra、Okta 对比
上一篇:PAM、IGA 与审计合规
下一篇:返回系列索引
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】IAM 全景:为什么这是高价值赛道
身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。
【身份与访问控制工程】SAML 还值得学吗:企业遗留 SSO 的现实世界
SAML 2.0 是 2005 年的协议,却仍活在每一个和银行、保险、制造业做生意的 B2B SaaS 后台——本文从一封客户邮件开始,拆解 SAML 断言结构、SP-initiated 流程、Metadata、证书轮换与 XML 签名包装攻击等现实工程问题
【身份与访问控制工程】CIAM 架构:面向 B2B / B2C SaaS 的身份平台
系统梳理 CIAM(Customer Identity and Access Management)的场景差异、数据模型、隐私合规与工程坑点,覆盖 B2C 社交登录、B2B 企业 SSO/SCIM、B2B2C 组织模型,以及全球多区域部署与选型建议。
【身份与访问控制工程】SCIM 与账号生命周期:开通、变更、离职自动化
从一起僵尸账号安全事件切入,系统讲透 SCIM 2.0 的资源模型、协议操作、Push/Pull 模式、主流 IdP 差异,以及服务端实现、幂等、软删除、孤儿账户清理等工程落地细节