之所以单独写出来,是因为我观察到很多博客里面,会对JWT,oauth2,乃至sa-token是什么产生一些混淆。
如果仅在后端开发学习的场景下,这问题不算是很大。但是如果供安全相关从业者学习,或者希望深入学习各种鉴权的从业者来说,还是容易产生误解的。
在开篇,我先以JWT,OAuth2和sa-token做一个简单的区分。这三个东西似乎都被用在鉴权领域里,为什么不算是同一类东西呢?
- JWT(JSON Web Token): 它本质上是一种数据格式与签名规范(RFC 7519),用来安全地传输声明。它的结构非常标志性:
xml
<Header>.<Payload>.<Signature>
- OAuth2 / OIDC: 它们是协议与标准。
- OAuth2 是一个授权框架,解决的是第三方应用访问受保护资源的问题。
- OIDC(OpenID Connect) 则是基于 OAuth2 构建的身份认证协议,专门用于解决"用户是谁"的登录认证问题。
- Sa-Token: 它是一个Java权限认证框架(类似于 Spring Security 或 Apache Shiro),专注于登录认证、权限验证、单点登录、踢人下线等业务场景的便捷实现。
我们以小明同学借助微信授权登入第三方平台A为例:
- 第一阶段:授权/认证阶段(OIDC流程):
- 小明在平台 A 点击"微信登录",被重定向到微信认证服务。
- 微信确认小明身份后,颁发了一个
Authorization Code(授权码)给浏览器,由浏览器带给平台 A 的后端。(这个地方有一种说法是,微信直接把auth code给后端了。后面我考证了一下,准确的流程还是先给浏览器,再由浏览器带给后端。只是在语义上,可以认为是微信给平台后端的。) - 平台 A 后端拿着这个
Authorization Code再次请求微信服务,换取了Access Token和ID Token - 平台 A 利用
Access Token调用微信资源服务器,获取小明的昵称、头像、OpenID 等信息,并在平台 A 完成本地账号的注册或绑定,至此完成了基于 OIDC 的第三方登录。
- 第二阶段:登陆状态维持:
- 为了维持小明同学的登录状态,此时,平台 A 还需再自己给小明颁发一个
token维持小明的登录状态。
在这个故事里面,我们前后其实谈到了两个token,一个是微信颁发给平台后端的access token,一个是平台颁发给小明的token。
那么这两个token和我们讨论的内容有什么关系呢?
- 关于故事中微信或者其他第三方平台颁发的
access token,它完全可以是JWT格式,也可以是其他的加密随机字符串的格式 - 关于平台颁发给小明的
token,它也可以是JWT格式,还可以是cookie/sessionid格式,还可以是放在document.cookie里面的JWT格式token。
相信这个例子,足以让我们区分token格式,协议标准和安全开发框架。
而在这篇博客里面,我们主要讨论的是前者,诸如JWT的token格式。
1. JWT / PASETO
1.1 JWT
JWT 是目前应用极其广泛的一种 Token 格式。
JWT主要由三个部分组成,每部分使用 Base64Url 编码后以.分隔
xml
<Header>.<Payload>.<Signature>
- 头部 (header)用于描述令牌元信息,包括签名算法和令牌类型。典型示例如下:
json
{
"alg": "HS256",
"typ": "JWT"
}
-
载荷 (payload)包含实际要传递的信息,是 JWT 的核心部分,主要包括:
- 注册声明(Registered Claims):标准字段,如
- iss(Issuer)签发者
- sub(Subject)主题,通常为用户唯一标识
- aud(Audience)接收方
- exp(Expiration Time)过期时间
- nbf(Not Before)生效时间
- iat(Issued At)签发时间
- jti(JWT ID)唯一标识符
- 注册声明(Registered Claims):标准字段,如
-
公共声明(Public Claims):应用可自定义字段,用于存放角色、权限、部门 ID 等信息。
-
私有声明(Private Claims):完全由应用定义的字段,用于携带业务特定信息,如用户偏好、单点登录标识等。
-
签名(signature)主要由一下公式来声明,从算法上来看,很明显,这个是为了防篡改,以及完整性的。
scss
Signature = HMACSHA256( base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret )
签名部分是JWT做无状态认证的关键,服务器无需保存任何会话的信息,只需要把header+payload丢进签名算法里面,然后比对和签名是否一致就行为了。
我们在一些简单的后端认证场景里面会看到这样的写法
c
用户登录 ──> 后端签发 JWT(后端自身不落库保存) ──> 客户端存储并后续携带访问 ──> 后端验签(校验 userid, role, exp 等核心字段) ──> 正常放行响应
那么从那个JWT的原理上来看,JWT的安全性要考虑的问题就在于:
- secret的安全,这包括secret的强度以及保密,及时轮换
- 不在payload部分放置敏感的数据,base64并不算是加密
- 正确且完整的验签,必须严格校验签名和过期时间(exp),防止算法被篡改(如常见的 alg: none 漏洞利用)或重放攻击。
- 最后,也是常常容易忽略的,有设计有效的撤销机制。因为 JWT 本身是无状态的,在到达过期时间之前,单凭 Token 本身无法在服务端直接作废。常见的做法是结合 Redis 建立一个 Token 黑名单/注销列表,并在用户登出或账号被封禁时将 Token 放入黑名单中(配合定期清理过期的黑名单数据以节省内存)。
1.2 PASETO
PASETO之所以被放在这里,因为它算是一种更加先进的JWT。
PASETO出现的一种原因在于,JWT本身其实是安全的,但是容易被开发者不安全地实现------不安全的加密算法,算法混淆,混淆加密和签名把敏感信息放载荷。
PASETO对此提供一种默认安全的实现,或者说更容易安全实现,尽可能避免开发者错误选择。
格式上PASETO如下
version.purpose.payload.footer
- version: 很显然是版本号
- purpose: 用途
- payload: 载荷数据
- footer: 附加信息
其中具有特色的是明确的purpose,有且仅有两种参数选择:local和public
- local模式下是加密的
token,类似于AES-GCM,内容保密,token之中的载荷攻击者无法看到 - public模式下是数字签名
token,类似于JWT RS256,内容公开,但是无法修改。
更详细的格式区分:
vbnet
local:
v4.local.<encrypted_payload>.<footer>
public:
v4.public.<payload(base64明文)>.<signature>.<footer>
被签名的部分:
PAE(
"v4.public",
payload,
footer,
implicit_assertion
)
总结来说就是,相对于JWT,PASETO具有的优势有:默认安全的实现;可以通过local模式进行真正的加密;有固定的加密算法,防止算法降级;然后例如V4版本使用更为现代的加密算法(例如Ed25519,XChaCha20)。
而从其原理上来看,很明显应该注意的安全问题有:
- PASETO依旧没有撤销机制,需要配合Redis黑名单等机制使用
- 需要确保密钥的安全性
- 完善的验签机制
- 多一点的是,不要误以为PASETO更加安全,就可以在payload里面放敏感数据。public情况下,payload依旧是不加密的。
总得来说,PASETO的使用目前应该是没有JWT多的,生态也没有那么完善的。像OAuth生态下的支持目前也不是那么的好。
究竟是JWT还是PASETO要看需求而定,正确实现的JWT也不失安全性。
2. Opaque Token / Reference Token(不透明的token/引用token)
与自带明文信息的 JWT 不同,Opaque Token(不透明 Token) 本身是一个没有任何业务含义的随机字符串。(例如a8f91bc729d82ff0192)
在国内这种token更为场景的称呼是随机token,UUID 令牌,或者直接结合Redis session来用。
具体工作流程:
- 用户登录成功后,后端生成一个高熵的随机字符串作为 Token,并将该 Token 与用户的会话信息(如 User ID、权限、登录状态等)绑定存入 Redis 或数据库中。
- 客户端在后续请求中将该 Token 通过请求头或 Cookie 带给后端。
- 后端拿到 Token 后,直接去 Redis 或数据库中进行查询:如果查不到或者已过期,则说明未登录或失效;如果查询成功,则取出关联的用户信息进行后续业务处理。
它的底层设计理念和传统的 Cookie/Session 机制非常相似------状态保存在服务端,客户端只持有一个用于检索的凭证。
对比于JWT,opaque token / reference Token 的好处很明显,它的载荷里面不太会被错误放入敏感信息;另外撤销更加容易。
缺陷在于,所有的token都需要存储在Redis/数据库中,需要考虑更高的开销。当然,这一部分是后端考虑的事情。从安全性的角度,我们可能需要考虑更多:
- 作为一个高熵的随机字符串,它的生成算法需要足够的安全(安全的伪随机数生成算法),它的长度也需要足够(128位以上),以避免被暴力猜解或者碰撞。(如 Java 的 SecureRandom、Node.js 的 crypto.randomBytes)
- 因为需要存储在Redis/数据库之中,token作为一个敏感信息,需要享有和密码一样的待遇。最好不要明文直接地存储,而是存储其hash值,且使用安全的hash算法。这样即便被脱库了,token也不会严重泄露。这一安全考量同样适用于各类需要持久化存储的 Token。
- 最后是严格的生命安全周期和撤销机制。
3. API Key / AKSK
3.1 API Key
这个东西在大模型调用,或者其他第三方服务调用的场景之下相当常见。
从本质上来看,API Key 是由服务器生成的一串高熵随机字符串。当用户携带 API Key 访问服务时,服务端会根据该 Key 查询并校验对应的信息,例如访问范围、权限、过期时间及调用额度等。
典型的校验流程大概如下:
markdown
1. 从请求头读取 API Key
2. 对 API Key 做哈希计算
3. 在数据库中查找对应记录
4. 检查 Key 是否存在
5. 检查是否被禁用或过期
6. 检查接口权限
7. 检查 IP、来源和调用频率
8. 记录日志和计费信息
9. 允许或拒绝请求
从本质上讲,API Key 类似于一种不透明令牌(Opaque Token),那么从安全性的角度,我们分为两个方面考虑,一方面是服务提供方,另一方面是服务使用方。
-
对于服务提供方而言:
- 我们抓住关键词"高熵随机字符串",很好,这告诉我们,必须使用密码学安全伪随机数生成器(CSPRNG),以及字符串的长度要足够(建议至少 128 位到 256 位随机熵,编码后通常表现为 32 位以上的字符串)。
- 我们再抓住需要服务器数据库进行存储这个关键点,这告诉我们,不要明文存储这个敏感数据,要使用和密码一样的待遇:安全的现代哈希算法,比对时只比对哈希。
- 合理的生命周期管理,合理的撤销机制......
- 必须通过 HTTP 请求头(如 Authorization 或自定义 Header)传递,尽量不要放在 URL 查询参数(Query String)或请求体中,以防因浏览器历史记录、Referer 泄露或网关访问日志记录而导致明文暴露。
-
对于服务使用方而言:
- 它需要享有`secret的待遇,不要硬编码,注意保密。
- 仅在安全的协议下传输
- 必要的情况下,需要有合适的自动轮换机制
3.2 AKSK (Access Key / Secret Key)
K/SK 是一种比普通 API Key 更为严格、常用于云厂商(如 AWS、阿里云、腾讯云等)的开放接口认证方式。
一种粗糙的理解是
ini
AK = 用户名 / 钥匙编号(公开的)
SK = 密码 / 真正的钥匙(秘密的)
需要注意的是,它与传统的"账号密码表单式登录"有本质区别:在传统登录中,Password 通常需要在网络中传输(即使加密)。
而在 AK/SK 认证机制下,SK 绝不会在网络中传输。这也是 AK/SK 相比普通 API Key 更为安全的核心原因。
流程具体而言就是:
sql
--------------
客户端(Client)
AK + SK + 请求内容(Method、URI、Headers、Body 等)
↓
计算加密签名(Signature)
↓
发送:AK + 签名 + 时间戳 + 随机数(Nonce)给服务端
---------------
服务器端
1. 从请求中读取 AK
2. 根据 AK 在数据库/KMS 中查找到对应的 SK(以及关联的用户/权限)
3. 检查时间戳与随机数,防范重放攻击
4. 使用与客户端完全相同的规则,拼接并生成"待签名字符串"
5. 使用查出来的 SK 重新计算服务端签名
6. 严格比对客户端签名与服务端签名是否一致
7. 检查接口权限、调用频率(Rate Limiting)
8. 允许或拒绝请求
我们依旧从两个视角讨论安全问题:
-
服务提供方:
- SK的生成安全(密码学安全的随机数生成器+足够长)
- SK的存储安全(这个地方相对于API Key的麻烦指出在于,SK没法只存哈希,否则将无法进行签名校验,这会更依赖数据库的安全。因此服务端必须采用**高强度的对称加密(如 AES-GCM)**将 SK 加密后存储在数据库中,并配合密钥管理服务(KMS)严格保护主密钥,防止数据库拖库导致 SK 整体泄露。)
- 合理的生命周期管理,合理的撤销机制......
-
服务使用方:
- SK的保密
- 不要误传SK
- 必要的情况下,需要有合适的自动轮换机制
4. SAML Assertion
其实,这一部分原本并不想讨论。SAML 本身是一套协议,讨论 SAML Assertion 就很难完全绕开 SAML 协议,这会有点偏离本文介绍常见 Token 的核心。
但是,最终这个部分还是被加上了,因为 SAML Assertion 确实是一种基于 XML 的安全断言,也常被作为安全令牌使用。
举个例子,简单说明一下 SAML 协议的工作流程:
text
小明同学需要使用学校的教务系统(服务提供商,SP)。他尝试登录教务系统,
页面被重定向到了学校的统一身份认证平台(身份提供商,IdP)。
小明在 IdP 完成身份认证后,IdP 生成一个 SAML Assertion(断言),其中可能包含:
用户名:小明
身份:学生
所属组织:某某学校
......
[IdP 数字签名]
随后,浏览器将包含该断言的 SAML Response 提交给教务系统(SP)。
教务系统验证签名及断言中的有效期、接收方等条件后,
为小明创建本地登录会话。随后,小明便可以正常使用教务系统。
这个流程听起来和 OAuth2/OIDC 有点像,确实存在一些相似之处。
SAML Assertion 由 IdP 签发,用于向 SP 声明某个主体的身份认证情况以及相关属性。例如,它可以声明小明已经通过身份认证、小明的身份标识是什么、所属部门是什么。至于这些属性最终对应哪些业务权限,通常由 SP 根据本地规则进行映射和判断。
OAuth 2.0 中的 Access Token 本质上是一种授权凭证,代表客户端获得了访问特定受保护资源的权限,它本身不直接等同于用户身份信息,常用于客户端代表用户或以自身身份调用资源服务器。若需要在 OAuth2 体系之上完成用户身份认证,通常需要结合 OpenID Connect,并使用 ID Token 表达身份认证结果。
SAML Assertion 的一个典型结构如下。
它通常由可信的身份提供商(IdP)签发,是一份包含主体、限制条件以及身份或属性声明的 XML 文档:
xml
<Assertion>
<Issuer>
https://idp.example.com
</Issuer>
<Subject>
<NameID>Alice</NameID>
</Subject>
<Conditions
NotBefore="2026-08-03T10:00:00Z"
NotOnOrAfter="2026-08-03T10:05:00Z">
<AudienceRestriction>
<Audience>https://sp.example.com</Audience>
</AudienceRestriction>
</Conditions>
<AuthnStatement>
...
</AuthnStatement>
<AttributeStatement>
...
</AttributeStatement>
<Signature>
<SignedInfo>
...
</SignedInfo>
<SignatureValue>
...
</SignatureValue>
<KeyInfo>
...
</KeyInfo>
</Signature>
</Assertion>
整个签名过程可以简化理解为:
text
1. 对需要保护的 XML 数据执行规定的转换和规范化处理。
2. 对被引用的数据计算摘要,并将结果写入 DigestValue。
3. 对 SignedInfo 元素进行规范化。
4. IdP 使用私钥和指定的签名算法,对规范化后的 SignedInfo 生成数字签名,
并将结果写入 SignatureValue。
5. SP 使用 IdP 的公钥验证 SignatureValue,同时重新计算并校验各个引用数据的摘要。
6. 签名验证通过后,SP 还需要继续检查签发者、有效期、Audience、
Recipient、SubjectConfirmation 等条件。
由于 XML 具有较强的表达能力,SAML Assertion 可以承载身份标识、身份认证信息、组织属性和角色等丰富内容。当然,结构越复杂,其生成、传输和解析的开销通常也会相应增加。
那么,从安全层面上,仅看 SAML Assertion 的部分,值得考虑的问题与 JWT 存在一些相似之处:
- SAML Assertion 通常会进行数字签名,但数字签名并不等于加密。因此,不应仅依赖签名来保护其中的敏感信息。如果确实需要传递机密信息,可以使用 XML Encryption,并且传输过程仍应使用 TLS。
- SAML Assertion 通常具有较短的有效期,但其本身一般没有类似服务端会话那样的即时撤销能力。因此,需要严格校验
NotBefore、NotOnOrAfter、Audience、Recipient、InResponseTo等字段,并结合短有效期、请求关联和断言使用记录等措施防止重放。如果需要立即终止用户访问,还需要同时处理 SP 本地会话或其他服务端状态。 - 整体安全性高度依赖 IdP 私钥及信任配置的安全性。
- SP 不能只检查"签名是否正确",还必须确认签名保护的确实是随后被业务逻辑使用的那个 Assertion,并完整校验断言的签发者、接收方、时间限制和主体确认条件。
总结
从我们讲的上述的例子来看,其实我们可以观察到,如何保证某种token的安全,其实是和token本身的性质有关的。
这涉及到:
- 后端服务器存不存?(有状态无状态?)
- 无状态算法,常常严重依赖于secret的安全性。存储的安全吗?有没有硬编码?长度够不够?随机轮换?
- 有状态算法,常常需要后端Redis/数据库进行存储,要考虑怎么存(安全哈希?无法哈希的对称加密?)
- 怎么生成的?(token的生成算法)
- 载荷加不加密?不加密就不能够放敏感信息
- 不透明 Token(Opaque Token / Reference Token),那生成随机数算法是否密码学安全?token够不够长?
- 浏览器存哪里?(sessionStorage / Cookie /localStorage ?)这一部分要复杂的多,这里仅仅简述。
- 存document.cookie,有没有考虑HttpOnly,samesite,csrf-token等手段防止XSS和CSRF?
- 存localStorage,有没有考虑严格的CSP防xss?
依据这些,我们可以分析一个token设计是否安全,以及要考虑哪些安全。