
你有没有遇到过这种场景:后端同事说"你们前端根本不懂 OAuth",面试官问"JWT 和 Session 有什么区别"你支支吾吾,或者接入第三方登录时一头雾水? 今天咱们一次性把这些全搞清楚——不背概念,从生活场景讲起,再到代码落地。
你每天上班进公司,保安会检查你的工牌。
这个动作背后藏着两个问题:
API 鉴权干的是同一件事——每次请求到达服务器,服务器都在问:
"你是谁?我为什么要信任你?"
不同的鉴权方案,本质上是对这两个问题给出不同的回答方式。
接下来咱们按照历史顺序,把 8 种方案的来龙去脉讲清楚。每种方案都是为了解决上一种的问题而生的——理解这条进化链,比背八个定义要有用得多。
想象你每次进公司,都要把身份证原件交给保安,保安复印存档,再把身份证还给你。
下次再进来?再交一次。每次都这样。
Basic Auth 就是这个逻辑——每次请求都把用户名+密码带上,服务器验证一次才放行。
浏览器会把"用户名:密码"用 Base64 编码一下(注意:编码≠加密,只是换了个写法,任何人都能解开),然后放进请求头:
Authorization: Basic dXNlcjpwYXNzd29yZA==
// 把上面那串解码,就是:user:password
// 所以必须用 HTTPS,不然在网络上裸奔
服务器收到后,解码,去数据库查用户名密码对不对,对了就放行。
每次请求都要查一次数据库。
假设你的 App 有 10 万用户同时在线,每秒产生 100 万次请求,就意味着 100 万次数据库查询。这会把数据库直接压垮。
这个问题推动了 Session 的诞生。
内部管理后台、公司内网工具、快速测试——这些场景用还行。面向普通用户的产品,千万别用。
你去一个高档会所,第一次进来时前台验证了你的身份,然后给你一张会员卡(上面写着你的会员编号)。
以后每次来,你只需要刷会员卡,前台查一下"这个编号对应的是谁"就行了——不需要你每次都掏身份证了。
Session 就是这个逻辑。
第一步:你登录,发送用户名+密码(只需要这一次)
│
▼
第二步:服务器验证通过,在数据库创建一条 Session 记录
Session 记录长这样:
{ sessionId: "abc123", userId: 42, 过期时间: 24小时后 }
│
▼
第三步:服务器把 sessionId 写进 Cookie,发给你的浏览器
Set-Cookie: sessionId=abc123; HttpOnly; Secure
│
▼
第四步:之后每次请求,浏览器自动带上这个 Cookie
Cookie: sessionId=abc123
│
▼
第五步:服务器拿着 abc123 去查数据库,找到对应用户
→ 知道你是谁,放行 ✓
其中有个关键词:HttpOnly
这个属性告诉浏览器:"这个 Cookie 只能在 HTTP 请求里用,JavaScript 代码不能读它。"这样即使页面被注入了恶意 JS 代码,也偷不走你的 sessionId。
// Node.js + Express 设置 Session 的代码
app.use(session({
secret: process.env.SESSION_SECRET, // 加密用的密钥,别写死在代码里
store: new RedisStore({ client: redis }), // Session 存 Redis,不然重启就丢了
cookie: {
httpOnly: true, // JS 读不到,防止 XSS 盗取
secure: true, // 只在 HTTPS 下发送
sameSite: 'strict', // 防止 CSRF 攻击
maxAge: 24 * 60 * 60 * 1000 // 24 小时后过期
}
}));
Session 的数据存在服务器上,这叫"有状态"。
单台服务器没问题。但现代应用通常有很多台服务器同时运行(防止单点故障、应对高并发),问题就来了:
你的请求 → 负载均衡 → 服务器A(有你的 Session)✓ 认识你
你的请求 → 负载均衡 → 服务器B(没有你的 Session)✗ 不认识你!
解决方法是把所有 Session 集中存到 Redis 里,但这又多了一个需要维护的基础设施,而且 Redis 挂了就全完了。
所以:Session 适合单台服务器的应用,在分布式系统里用起来比较麻烦。
你给快递员配了一把小区大门的专用钥匙,这把钥匙只能开大门,进不了你家。
钥匙丢了?再配一把,把旧的作废就行,不影响你家里的其他钥匙。
API Key 就是这个逻辑——给程序用的、可以独立管理的访问凭证。
你在 Stripe(支付平台)注册,它给你一个 Key:sk_live_abc123...
你的后端代码每次调用 Stripe API 时带上它:
GET /v1/charges HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_live_abc123def456
Stripe 服务器查一下这个 Key 对应哪个账户,检查权限,然后处理请求。
有一个血泪教训:绝对不要把 API Key 放在 URL 参数里!
❌ 危险:GET /api/data?api_key=abc123
↑ 这个 URL 会出现在服务器日志里,别人一翻日志就能看到
✓ 正确:放在 HTTP Header 里
import crypto from'crypto';
// 生成 API Key
function generateApiKey(): string {
return`sk_${crypto.randomBytes(32).toString('hex')}`;
}
// ⚠️ 重要:存数据库时不能存明文,要存哈希值
// 就像密码一样,万一数据库泄露,别人也拿不到真实的 Key
const rawKey = generateApiKey(); // 这个只展示给用户一次
const hashedKey = crypto
.createHmac('sha256', process.env.HASH_SECRET!)
.update(rawKey)
.digest('hex'); // 存这个到数据库
await db.apiKeys.create({ hash: hashedKey, userId, scopes: ['read'] });
API Key 识别的是"哪个应用在调用",不是"哪个用户在操作"。 这是它的本质局限。
很多人把 Bearer Token 当成一种鉴权方案,其实它只是一个 HTTP 请求头的写法规范,不是鉴权机制本身。
Authorization: Bearer 这里放什么都行
"Bearer"这个词的意思是"持有人"——谁持有这个 token,谁就能访问。
至于 token 里面是什么?可以是:
你可以把 Bearer Token 理解成一个"信封格式",里面装什么要看具体的鉴权方案。
理解了这一点,下面讲 JWT 和 OAuth 就不会再混淆了。
回顾一下 Session 的问题:用户信息存在服务器,分布式系统里很麻烦。
JWT 的思路反过来:把用户信息直接打包进 Token,发给客户端存着,服务器不用存任何东西。
就像把你的个人档案装进一个防伪信封,信封上有官方印章(签名)。任何机构拿到这个信封,验证印章真实,就知道里面的内容是可信的——不需要打电话去总部查档案。
一个 JWT 长这样(三段用 . 分隔):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcwMDAwMDAwMH0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
把这三段解码,分别是:
第一段(Header):告诉你用什么算法签名的
{
"alg": "HS256",
"typ": "JWT"
}
第二段(Payload):用户信息,随便放什么
{
"sub": "user_123", // 用户 ID
"role": "admin", // 角色
"email": "zhang@example.com",
"exp": 1700003600 // 过期时间(Unix 时间戳)
}
第三段(Signature):签名,用来防止有人篡改前两段
HMACSHA256(第一段 + "." + 第二段, 服务器的密钥)
关键:Payload 不是加密的,只是 Base64 编码!任何人都能解开看到里面的内容。所以不要往里面放密码、手机号这类敏感信息。
登录
│
▼ 服务器验证通过,用密钥签名生成 JWT,返回给你
│
▼ 你的浏览器/App 保存这个 JWT
│
▼ 之后每次请求,带上 JWT:
Authorization: Bearer eyJhbGci...
│
▼ 服务器收到后:
1. 用同一个密钥验证签名(确认没被篡改)
2. 检查 exp 字段有没有过期
3. 从 Payload 里直接读取用户信息
→ 全程不需要查数据库!
import jwt from'jsonwebtoken';
// 登录成功后,生成 JWT
const token = jwt.sign(
{
sub: user.id,
role: user.role,
email: user.email
// ❌ 不要放:password、phone、idCard 等敏感信息
},
process.env.JWT_SECRET!, // 这个密钥要好好保存,泄露就完了
{ expiresIn: '15m' } // 15 分钟过期,为什么这么短?下面解释
);
// 每次请求时,验证 JWT 的中间件
const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
try {
// 从请求头里取出 token
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '没有提供 token' });
}
// 验证签名 + 检查过期时间(jwt.verify 自动做这两件事)
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; role: string };
req.user = decoded; // 把用户信息挂到请求上,后续代码直接用
next();
} catch (err) {
res.status(401).json({ error: 'token 无效或已过期' });
}
};
想象你的会员卡丢了,你去前台挂失,前台说:"没办法,这张卡在过期之前都有效,等它自己过期吧(还有 30 天)。"
这就是 JWT 的问题——你没法强制让一个 JWT 立刻失效,只能等它自己过期。
用户被封号了?Token 照样能用。用户改了密码想踢出所有设备?做不到。
解决方案:双 Token 机制
登录成功,同时颁发两个 token:
├── access_token(15分钟过期)← 用来访问 API,过期了就换
└── refresh_token(7天过期,存数据库)← 用来换新的 access_token
正常请求:用 access_token
access_token 过期:用 refresh_token 换一个新的 access_token
退出登录:从数据库删除 refresh_token(立刻失效,下次刷新就换不出来了)
封号用户:删掉他的 refresh_token,access_token 最多再撑 15 分钟
这样,"无法主动吊销"的影响窗口最大只有 15 分钟,大多数场景够用了。
坑 | 原因 | 正确做法 |
|---|---|---|
Payload 放密码/手机号 | Base64 可以直接解码,不是加密 | 只放用户ID、角色等非敏感信息 |
Token 存 localStorage | JS 可以读取,XSS 攻击直接偷走 | 存 HttpOnly Cookie |
过期时间设 30 天 | 等于永不过期,泄露了无法挽救 | access_token 最长 15 分钟 |
不验证签名算法 | 攻击者可以把算法改成 "none" | 明确指定接受的算法 |
你租了一间民宿,房东给你一把只能开你住的那间房的钥匙,你不需要知道房东家里主卧的密码,你也进不了其他房间。
"用 GitHub 登录某个网站"——你不需要把 GitHub 密码告诉那个网站,但那个网站可以读取你 GitHub 上的公开信息(你允许的范围内)。
这就是 OAuth 2.0 解决的问题:在不交出密码的情况下,授权第三方访问你的数据。
OAuth 2.0 是授权协议,不是认证协议。 它告诉第三方 "你可以访问用户的这些数据",但不告诉第三方"这个用户是谁"。
以"用 GitHub 账号登录掘金"为例:
你(资源所有者) ← 你的 GitHub 数据的主人
掘金网站(客户端) ← 想要访问你数据的第三方应用
GitHub 授权服务器(Auth Server)← 负责问你"同意吗"的中间人
GitHub API(资源服务器) ← 真正存着你数据的地方
第一步:你在掘金点击"用 GitHub 登录"
│
▼
第二步:掘金把你的浏览器跳转到 GitHub 授权页
URL 里带着:掘金的 client_id、跳回哪里、要什么权限
GET https://github.com/login/oauth/authorize
?client_id=掘金的应用ID
&redirect_uri=https://juejin.cn/callback
&scope=read:user,user:email ← 申请读取用户信息的权限
&state=随机字符串 ← 防止 CSRF 攻击用的
│
▼
第三步:你在 GitHub 上点"同意授权"
│
▼
第四步:GitHub 把你的浏览器跳回掘金,带着一个短命的"授权码"
https://juejin.cn/callback?code=abc123&state=随机字符串
│
▼
第五步:掘金后端服务器拿着这个 code,悄悄去找 GitHub 换 token
(这一步在服务端发生,你的浏览器看不见)
POST https://github.com/login/oauth/access_token
{ client_id, client_secret, code }
│
▼
第六步:GitHub 返回 access_token
│
▼
第七步:掘金用 access_token 调用 GitHub API,读取你的公开信息
为什么要多一个 code,不直接返回 token?
因为第四步的 URL 会出现在浏览器地址栏、历史记录、服务器日志里,太危险了。code 是一次性的而且极短命,即使被截获也没用。真正的 token 在第五步通过服务端安全通道获取。
// 第五步:用 code 换 token(在你的后端实现)
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET, // 这个绝不能泄露
code: req.query.code,
})
});
const { access_token } = await tokenRes.json();
// 第七步:用 access_token 获取用户信息
const userRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${access_token}` }
});
const githubUser = await userRes.json();
// { login: "zhangsan", email: "...", avatar_url: "..." }
你拿到 access_token 后,你知道的只是"这个 token 有权限访问某个 GitHub 账号",但你不知道这个账号对应的用户信息(除非再调一次 API)。
这就是 OIDC 要解决的问题。
OIDC = OAuth 2.0 + 用户身份信息
OAuth 给你一把钥匙,OIDC 在钥匙上刻了"这把钥匙的主人叫张三,邮箱是 xxx"。
在请求权限的时候,scope 里加上 openid:
scope=openid email profile
这样授权服务器除了返回 access_token,还会额外返回一个 id_token。
id_token 是一个 JWT,里面直接包含了用户信息:
{
"iss": "https://accounts.google.com", // 谁签发的(Google)
"sub": "1234567890", // 用户在 Google 的唯一 ID
"email": "zhang@gmail.com",
"name": "张三",
"picture": "https://头像URL",
"aud": "你的应用ID", // 这个 token 是给谁的
"exp": 1700003600 // 过期时间
}
你验证这个 JWT 的签名(用 Google 的公钥,会自动从固定地址获取),验证通过,就立刻知道用户是谁了——不需要额外再调 API。
每次你点"用 Google/GitHub/Apple 登录",背后跑的都是 OIDC 流程。
进公司,用工号密码登录 OA 系统。然后打开飞书——已经登录了。打开内部 GitLab——也已经登录了。
你只登录了一次,但所有系统都认识你,这就是 SSO(Single Sign-On,单点登录)。
有一个所有系统都信任的"大哥"——身份认证中心(IdP,Identity Provider)。
所有系统都问大哥:这个人可以信任吗?
┌─────────────────────────────────────────────┐
│ 身份认证中心(大哥/IdP) │
│ (Okta、Azure AD、或公司自建的系统) │
└──────────┬──────────────────────────────────┘
│ 管理所有用户的登录状态
│
┌──────┼──────────┐
▼ ▼ ▼
OA 飞书 内部 GitLab
(小弟们,都听大哥的)
流程:
你第一次访问飞书
→ 飞书:"你没登录,去大哥那里确认一下"
→ 大哥:"你有登录记录吗?"
→ 没有 → 你输入工号密码 → 大哥验证通过 → 建立 SSO Session
→ 大哥给飞书一个证明 → 飞书放你进去 ✓
你之后访问 OA
→ OA:"你没登录,去大哥那里"
→ 大哥:"你有登录记录!" → 直接给 OA 一个证明
→ OA 放你进去 ✓ (你全程不用再输密码)
// 用 passport.js 接入公司的 Okta SSO(基于 OIDC 协议)
passport.use('sso', new OIDCStrategy({
issuer: 'https://your-company.okta.com',
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: '/auth/callback',
scope: 'openid email profile',
}, (tokenSet, userInfo, done) => {
// userInfo 里就是用户信息,直接用
// 通常还要查本地数据库,看这个用户在你系统里有没有账号
return done(null, userInfo);
}));
所有外部流量
│
▼
┌─────────────┐
│ API 网关 │ ← 统一在这里验证用户身份
│ 验证 token │ 然后把用户信息注入到请求头里
└──────┬──────┘
│ X-User-Id: 42
│ X-User-Role: admin
▼
┌────┼────┬───────┐
▼ ▼ ▼ ▼
服务A 服务B 服务C 服务D
(各自只处理业务,不用重复验证身份)
真实的生产系统,不同场景用不同方案:
┌──────────────────────────────────────────┐
│ 面向用户的 Web App │
│ 登录:OIDC(对接公司 SSO 或三方登录) │
│ 维持登录状态:JWT + HttpOnly Cookie │
│ 服务间调用:Bearer JWT 透传 │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ 对外开放的 API(给开发者用) │
│ 认证:API Key │
│ 权限控制:OAuth 2.0 Scope │
│ 限流:按 API Key 单独限制 │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ 公司内部服务互相调用 │
│ 认证:OAuth 2.0 Client Credentials │
│ 没有用户参与,纯机器对机器 │
└──────────────────────────────────────────┘
方案 | 一句话 | 适合用在 |
|---|---|---|
Basic Auth | 每次请求都带用户名密码 | 内部工具、快速原型 |
Session | 登一次,拿票,凭票进场 | 单体应用、管理后台 |
API Key | 机器用的永久通行证 | 服务端 API、第三方集成 |
Bearer Token | 一个请求头格式,装什么都行 | 配合 JWT/OAuth 使用 |
JWT | 用户信息打包进 Token,验签不查库 | 微服务、分布式系统 |
OAuth 2.0 | 授权别人访问我的数据,不给密码 | 三方登录、开放 API |
OIDC | OAuth + 顺便告诉你用户是谁 | 所有需要用户身份的场景 |
SSO | 一次登录,所有系统都放行 | 企业内部多系统 |
鉴权不是魔法,就是工程师们一个接一个地解决问题。理解每种方案解决了什么问题,面试里任何相关问题你都能从容展开来讲。
你在项目里用的是哪种方案?或者踩过哪些 Auth 相关的坑?
评论区说说——踩过的坑够多够典型,下期可以专门出一篇《真实项目里的 Auth 翻车故事》😄
关注「前端达人」,每周持续更新深度技术文章。 觉得有用的话,转发给还在懵圈的朋友——前端也能把 Auth 讲得比后端更清楚 😎