OIDC:登录背后的身份边界
从个人产品和 AI 应用的真实工程需求出发,聊聊 OpenID Connect 解决的到底是什么问题、什么时候适合使用、和 OAuth、SAML、CAS、LDAP、WebAuthn 等方案有什么区别。
最近把几个小应用的登录体系重新梳理了一遍,越做越觉得,很多应用真正绕不开的东西其实不多。
第一是登录。用户是谁,怎么进来,进来以后在系统里是什么身份。
第二是付费。用户为什么付钱,付了多少钱,付完以后在产品里能得到什么。
剩下的功能当然重要,甚至决定产品有没有价值。但如果登录和付费没有想清楚,应用就像一间开在路边的小店:门口没人看,账本也没人记。白天也许热闹,晚上盘账时就知道问题来了。
AI 时代以后,这两个问题反而更重要。以前一个网页工具被人多点几次,最多是带宽和服务器压力。现在很多 AI 应用每一次真正使用,后面都有模型调用、额度消耗和成本账单。未登录用户能不能用?免费额度算给谁?付费用户的套餐和权限放在哪里?一个用户从另一个应用过来,算不算同一个人?
这些问题最后都会回到一个很朴素的地方:身份。
OIDC 就是解决这个问题的一套标准方法。

English version: OIDC: The Identity Boundary Behind Login
先说清楚:OIDC 是什么
OIDC,全称 OpenID Connect。它建立在 OAuth 2.0 之上,用来做身份认证。
一句话说:
OAuth 2.0 主要回答:这个客户端能不能访问某个资源?
OIDC 主要回答:当前登录的这个人是谁?
平时我们说“使用 Google 登录”“使用 GitHub 登录”“公司统一账号登录”,严格讲,很多时候说的都不只是 OAuth,而是 OAuth 之上的身份协议,也就是 OIDC。
OIDC 最关键的产物是 id_token。它通常是一个 JWT,里面有几个重要字段:
{
"iss": "https://accounts.example.com",
"sub": "acct_123",
"aud": "my-web-app",
"exp": 1780000000,
"email": "user@example.com",
"email_verified": true
}
这里最重要的是三个:
iss:谁签发的身份。sub:这个人在签发方那里的稳定身份 ID。aud:这个身份结果是签给哪个应用的。
应用拿到 id_token 后,不是看一眼邮箱就算登录成功。它要校验签名、校验 iss、校验 aud、校验过期时间,再用 iss + sub 找到或创建自己的本地用户。
这件事看似麻烦,但它解决了一个非常根本的问题:身份不是靠前端说的,也不是靠 URL 里带的,更不是靠某个应用自己猜的。身份由一个明确的签发方签发,应用按标准验证。
这就是边界。
为什么不是自己写一套登录接口
小应用一开始最容易这么做:
POST /login
email + code
-> 返回 token
这当然能用。我也不反对在早期这么做。很多项目不是死在“不够标准”,而是死在还没上线就先给自己造了一座大教堂。
问题在于,应用一多,这套简单接口会慢慢变形。
第一个应用要邮箱登录。第二个应用要 GitHub 登录。第三个应用想接 Google。第四个应用又需要小程序登录。后台也要登录。CLI 也要登录。某个独立应用还想复用同一套账号。
如果每个应用都自己做一遍,很快就会出现这些问题:
- 用户在 A 应用登录了,到 B 应用又要重新注册。
- 同一个邮箱在不同应用里变成不同用户。
- 三方登录的回调、密钥、状态校验散在各处。
- 邮箱验证码、限流、风控、封禁、审计重复建设。
- 后面想做统一会员、跨应用权益、账号合并时,发现地基是散的。
登录接口本身不复杂,复杂的是长期维护身份关系。
OIDC 的意义就在这里。它不是让登录按钮变得更漂亮,而是把“谁来证明用户身份”这件事独立出来。
一个中心身份服务负责认证:用户是谁、用什么方式登录、邮箱有没有验证、账号是否被禁用。各个应用负责业务:这个用户在本应用里叫什么、有什么角色、有没有套餐、额度还剩多少、是否欠费。
这两个东西不要揉在一起。
中心身份和本地用户表
做统一登录时,很容易走向另一个极端:既然有统一身份中心,那是不是所有应用都共用一张用户表?
我的结论是,不要。
更稳的结构是:
统一身份中心
identity: acct_123
email: user@example.com
providers: email / google / github
应用 A
user_id: 1
auth_issuer: https://accounts.example.com
auth_subject: acct_123
role: admin
plan: pro
应用 B
user_id: 58
auth_issuer: https://accounts.example.com
auth_subject: acct_123
credits: 1200
status: active
中心身份回答“这是同一个人”。应用本地用户表回答“这个人在我这里是什么状态”。
这个分工很重要。
登录是全局问题,付费和权限往往是应用问题。一个人在写作工具里是会员,不代表他在图片工具里也有同样额度;一个人在后台里是管理员,不代表他在另一个产品里也该有管理权限;一个账号被中心禁用,所有应用都应该拦住,但某个应用里的业务封禁,也不一定要影响他使用别的应用。
如果把所有东西都塞进 id_token,最后 token 会变成一个小型数据库。它看起来很强,实际很危险。
id_token 适合放身份事实,不适合放频繁变化的业务状态。套餐、额度、积分、订单、角色、封禁原因,这些最好还是在应用自己的系统里查。

一个最小登录流程
OIDC 听起来有很多术语,但最常用的流程可以先按这一条理解:Authorization Code + PKCE。
大概是这样:
1. 用户在应用里点击登录。
2. 应用生成 state、nonce、code_verifier 和 code_challenge。
3. 应用把用户跳转到身份中心 /authorize。
4. 用户在身份中心完成登录。
5. 身份中心带着临时 code 跳回应用。
6. 应用校验 state。
7. 应用用 code + code_verifier 去 /token 换 token。
8. 应用校验 id_token。
9. 应用用 iss + sub 查找或创建本地用户。
10. 应用建立自己的 session。
这里有几个词容易混:
code 是一次性的临时票据,不是登录态。
id_token 是身份结果,用来证明用户是谁。
access_token 是访问资源的凭证,不应该被简单当成“用户是谁”的证明。
refresh_token 是续期凭证,能不能发、发给谁、怎么轮换,要非常谨慎。
对普通 Web 应用来说,最舒服的结构通常是:后端完成回调和 token 交换,后端校验 id_token,然后给浏览器写自己的 HttpOnly Cookie。浏览器不需要长期拿着身份供应商的 token 在前端跑来跑去。

一个极简的伪代码是:
app.get('/auth/start', (req, res) => {
const state = randomString();
const nonce = randomString();
const verifier = randomString();
const challenge = sha256base64url(verifier);
saveTemporaryLoginState(req, { state, nonce, verifier });
res.redirect(
'https://accounts.example.com/authorize?' +
new URLSearchParams({
response_type: 'code',
client_id: 'my-web-app',
redirect_uri: 'https://app.example.com/auth/callback',
scope: 'openid email profile',
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
}),
);
});
app.get('/auth/callback', async (req, res) => {
const saved = readTemporaryLoginState(req);
if (req.query.state !== saved.state) {
return res.status(400).send('bad state');
}
const tokens = await exchangeCodeForTokens({
code: req.query.code,
codeVerifier: saved.verifier,
});
const claims = await verifyIdToken(tokens.id_token, {
issuer: 'https://accounts.example.com',
audience: 'my-web-app',
nonce: saved.nonce,
});
const user = await findOrCreateLocalUser({
issuer: claims.iss,
subject: claims.sub,
email: claims.email,
});
writeLocalSessionCookie(res, user.id);
res.redirect('/dashboard');
});
真实项目里还要处理错误、过期、回跳地址白名单、Cookie 安全属性、日志和限流。但主干就是这几步。
什么场景适合用 OIDC
OIDC 适合这些场景:
第一,有多个应用需要共用登录。
哪怕现在只有两个应用,也值得提前想一下。登录一旦散出去,后面再收回来会很费劲。用户数据、三方账号、邮箱验证、旧 token、历史订单,全会变成迁移问题。
第二,想把“登录”和“业务用户”分开。
这对有付费、积分、额度、团队、角色、封禁等业务状态的应用尤其重要。中心身份不要变成超级业务用户表,应用本地用户表也不要重复造一整套登录供应商。
第三,需要接入第三方身份。
Google、Microsoft、企业身份、组织账号,这些天然适合按 OIDC 思路理解。即使某些平台表面文档写的是 OAuth 登录,背后也往往会给 id_token 或提供类似的身份验证能力。
第四,需要给 CLI、移动端、独立服务提供统一登录。
Web 可以走 Authorization Code + PKCE。CLI 在有浏览器时也可以走 PKCE;没有浏览器时,可以考虑 device code flow。移动端要用系统浏览器或平台推荐方式,不要让用户在不可信 WebView 里输入密码。
第五,未来可能做跨应用会员或统一账号治理。
付费本身不一定在身份中心里做,但身份中心至少要稳定回答“这是哪个人”。否则订单、权益、额度、风控很难跨应用协调。
什么场景不必急着用
不是所有项目都需要 OIDC。
如果只是一个很小的团队工具,只有一个 Web 端,用户也很少,简单 session 登录足够。
如果应用完全依赖某个平台,比如只在某个小程序里运行,用户体系也只围绕平台 openid 展开,那么先把平台登录做好更实际。
如果只是服务间调用,机器访问机器,重点不是“用户是谁”,而是“这个服务有没有权限”,那 API key、mTLS、OAuth client credentials 可能更合适。
如果只是想让用户不用输密码,WebAuthn / Passkey 是很好的登录方式,但它不是 OIDC 的替代品。Passkey 更像一种认证手段,OIDC 更像多个应用之间传递身份结果的协议。
还有一种情况也要谨慎:为了显得正规,先上一个很重的身份平台,然后业务还没跑起来,配置、回调、证书、client、realm、scope 已经把人绕晕了。
标准是为了降低长期复杂度,不是为了在第一天制造复杂度。
和几种常见方案的区别
OAuth 2.0 和 OIDC 最容易混。
OAuth 的核心是授权。比如一个应用想读取用户的网盘文件,用户同意以后,应用拿到 access token 去访问网盘 API。这里重点是“能不能访问资源”。
OIDC 在 OAuth 上加了身份层。它标准化了 id_token、sub、iss、aud、nonce、Discovery、JWKS、UserInfo。它让应用可以按标准确认“登录用户是谁”。
SAML 更老,也很常见,尤其在企业 SSO 里。它基于 XML,企业软件生态成熟,但对现代 Web 和移动应用来说,开发体验通常不如 OIDC 轻。
CAS 多见于高校和早期组织系统,单点登录能力很直接,但生态和现代 API 场景不如 OIDC 普遍。
LDAP / Active Directory 更像目录服务。它擅长保存组织、用户、组和凭据,也常用于企业目录认证。但直接让现代 Web 应用到处接 LDAP,通常不如先放一层 OIDC Provider。
Session Cookie 是单个 Web 应用最常见的登录态。它和 OIDC 不冲突。很多时候最稳的做法正是:外部登录用 OIDC,应用自己的登录态用 HttpOnly Session Cookie。
JWT 也不是 OIDC 的替代品。JWT 只是 token 格式。自己签一个 JWT 不等于实现了 OIDC。OIDC 关心的是签发方、受众、发现机制、公钥、流程和校验规则。
WebAuthn / Passkey 解决的是“用户如何证明自己”。OIDC 解决的是“应用如何相信一个身份供应商给出的身份结果”。它们可以一起用:用户用 Passkey 登录身份中心,应用通过 OIDC 接收身份结果。
AI 时代为什么更该懂这些概念
AI 让写代码变快了,但也让很多系统问题更早暴露。
以前一个应用可以靠一张用户表和几个接口撑很久。现在应用之间更容易互相连接,后台、CLI、Agent、移动端、独立服务、自动化任务都可能变成同一套产品能力的入口。
AI 也会让更多人参与构建系统。一个人可以同时做前端、后端、后台、部署、支付、登录。能力变强以后,最危险的不是写不出代码,而是不知道边界应该放在哪里。
登录尤其如此。
登录一旦做错,后面不是改几个页面那么简单。用户身份、付费记录、权限、风控、账号合并、注销、审计,全都绑在一起。代码可以让 AI 帮忙写,概念要自己想明白。
我现在越来越觉得,独立开发者和小团队至少要有几个底层判断:
- 登录不是一个按钮,是身份边界。
- 付费不是一个回调,是业务权益和账本。
- 中心身份不要吞掉应用用户表。
- 应用用户表也不要重复造身份中心。
- token 不是越多越好,scope 不是越大越好。
- 用户是谁,和用户能做什么,要分开建模。
这些判断不花哨,但很管用。
最后
OIDC 不是必须每个项目第一天就上的东西。
但只要应用开始变多,只要你希望用户在多个产品之间有同一个身份,只要你不想在每个应用里重复维护邮箱、三方登录、验证码、风控和账号合并,就应该认真理解它。
它解决的不是“怎么做一个登录页”,而是“谁有资格证明用户是谁”。
这个问题一旦想清楚,后面的很多设计都会顺一点:身份中心负责认证,应用负责业务;id_token 证明身份,本地 session 维持登录;iss + sub 做稳定映射,付费和权限留在应用自己的账本里。
技术协议最后都会落回这种普通道理。
门要有人看,账要有人记。一个应用若想长期活下去,登录和付费这两件事,总归不能糊涂。