1. 先搞清楚:JOSE到底解决什么问题
聊到JWT,大多数PHP开发者的第一反应就是firebase/php-jwt,三五行代码签发,十行代码验证,用起来确实爽。但是真的是所有场景都能这么顺吗?真不一定。当你遇到需要多算法切换、需要做对称与非对称签名选型、需要保证Token内容不被篡改甚至需要对载荷加密、需要对接多个系统的公钥密钥体系时,firebase/php-jwt那套"传个字符串密钥进去"的简单模型,马上就会让你觉得不够用了。
JOSE就是JSON Object Signing and Encryption的缩写,它是一套规范族,包含三个核心成员:JWS(JSON Web Signature)、JWE(JSON Web Encryption)、JWK(JSON Web Key)。我们日常说的JWT,其实只是JWS或者JWE的一种紧凑序列化表示方式。很多人没意识到,JWT只是表现层,下层真正干活的是签名和加密机制。
Namshi/JOSE这个库,就是JOSE规范在PHP生态里一个比较有代表性的实现。它和那些"给你一个门面类、只管生成Token"的轻量库不同,它更贴近规范本身:JWS负责签名,JWE负责加密,JWK负责密钥描述。你可以直接操作Header、Payload、签名器,也可以精确控制算法。代价是它用起来比php-jwt稍微多几步,但对理解整套机制非常有帮助。
这篇文章适合谁看?已经在项目里用过JWT,但只停留在"调用一下方法生成个串、再调用一下方法验一下"这类黑盒阶段的人。或者你正在做多应用统一认证、开放平台API签名、微服务间身份传递这类活儿,需要把签名算法、密钥格式、Token安全性讲清楚。看完这篇,你至少能手写一个JWS签名验证流程,能讲明白RS256和HS256的区别,也能知道JWE到底是怎么把Token内容藏起来的。我尽量不写那些"复制就能用、用错就抓瞎"的代码,每一段都讲清楚为什么这么写。
2. 安装与第一次完整实践:签发并验证一个JWS
2.1 依赖安装与版本选择
Namshi/JOSE是Composer包,安装非常简单。一般项目里直接跑:
composer require namshi/jose需要注意的一点是,这个库开发时间比较早,后面的维护节奏不算快。如果你用的是PHP 7.4以上的环境,建议装的时候看一下当前解析到的版本,确认和你的PHP版本兼容。我自己的做法是锁定最低版本要求,避免Composer把不兼容的旧版本解析进来。
装完之后,核心命名空间是Namshi\JOSE。常用到的类就这么几个:Namshi\JOSE\JWS负责签名和验签,Namshi\JOSE\JWT是底层数据结构封装,Namshi\JOSE\SimpleJWS是简化版入口。你还会看到Namshi\JOSE\Base64\Base64UrlSafe这个类,它就是base64url编解码工具,专门处理JWT里那套"去填充、替换字符"的编码规则。
2.2 签发一个最简单的HS256签名Token
先上一个最基础、也最常见的操作:用对称密钥做HS256签名。
use Namshi\JOSE\JWS; // 第一步:构造JWS实例,指定算法 $jws = new JWS(['alg' => 'HS256']); // 第二步:设置载荷,即你真正想携带的数据 $jws->setPayload([ 'uid' => 1001, 'username' => 'demo_user', 'exp' => time() + 3600, 'iat' => time(), ]); // 第三步:签名,参数就是双方约定好的对称密钥 $jws->sign('my-shared-secret-key'); // 第四步:拿到紧凑序列化后的Token字符串 $token = $jws->getTokenString();这四步做完,$token就是一个典型的JWT字符串,长这样:
eyJhbGciOiJIUzI1NiIsInR5cGUiOiJKV1QifQ.eyJ1aWQiOjEwMDEsInVzZXJuYW1lIjoiZGVtb191c2VyIiwiZXhwIjoxNzAwMDAwMDAwLCJpYXQiOjE2OTk5OTY0MDB9.abc123signature实际编码后是三个由点分隔的段。第一段是Header的base64url编码,第二段是Payload的base64url编码,第三段是签名结果。你手动把第一段解出来,会看到这样的JSON:
{"alg":"HS256","typ":"JWT"}注意这里有一个细节:typ字段。Namshi/JOSE默认构造Header时,如果你只传alg,它生成出来的typ是什么?实测不同版本表现不完全一样。有的版本会默认带JWT,有的不带。如果你对接的系统对Header有严格校验,比如要求必须存在typ,你最好在构造时明确写上:
$jws = new JWS(['alg' => 'HS256', 'typ' => 'JWT']);别在这个地方省事,对方系统校验严格的时候,少一个字段就是一条莫名其妙的报错。
2.3 验证签名并取出Payload
验证是我见过翻车频率最高的环节,并不是代码写错,而是很多人不知道verify()方法默认到底返回什么、验失败会抛什么。
use Namshi\JOSE\JWS; // 从请求方拿到Token字符串 $token = 'eyJhbGciOiJIUzI1NiIs...'; try { // 加载Token,解析出Header和Payload $jws = JWS::load($token); // 验证签名,密钥必须和签发时一致 $verified = $jws->verify('my-shared-secret-key'); if ($verified) { $payload = $jws->getPayload(); // $payload是一个数组,包含uid、username、exp、iat } else { // 签名对不上 } } catch (\Exception $e) { // Token格式不合法、base64解码失败、算法不支持等都会抛异常 }这里有个很容易踩的坑:JWS::load()这一步解析Token时,它对格式异常的情况相当敏感。如果Token不是一个合法的三段式结构,或者中间有非法字符,它大概率会抛异常,而不是返回一个"空对象"让你慢慢判断。
再强调一个安全习惯:verify()只能证明签名没被篡改,不能证明Token没过期。很多人以为验签通过就万事大吉,顺手就把exp给忽略了。实际上exp、nbf这类时间载荷是业务层校验,规范里要求的是"应用自行决定是否强制执行"。你自己不检查过期时间,那Token就是一个永久有效的通行证,安全问题就出在这种地方。
2.4 SimpleJWS快速入口
如果觉得每次都要手动new JWS有点啰嗦,库提供了一个简化类SimpleJWS。用法更直白:
use Namshi\JOSE\SimpleJWS; $jws = SimpleJWS::load($token); if ($jws->isValid('my-shared-secret-key')) { $payload = $jws->getPayload(); }它的isValid()方法内部会做两件事:验签、校验过期时间。对于很多普通内部系统来说,这个入口够用了。但我个人在实际项目中反而不太用它,原因有两个:第一,它对nbf、iat这类时间字段不做事先校验,容易产生认知偏差;第二,它封装得越简单,你越容易忽略底层到底校验了哪些东西。如果你要做跨系统对接,我还是推荐用原生JWS,自己把每一步都写在明处。
3. 核心机制拆解:签名算法与Token结构
3.1 HS256与RS256:对称签名与非对称签名的分水岭
这是JWT选型里最不该含糊的问题。
HS256全称是HMAC-SHA256,它是对称签名。也就是说,签名和验证用的是同一个密钥,你拿这个密钥能签,别人拿这个密钥也能签。它不解决"身份来源"问题,只解决"完整性"问题。只要密钥不泄露,Token内容任何一位被改动,验签必失败。代价是,所有需要验证Token的系统,都得持有同一个密钥。
RS256全称是RSA-SHA256,它是非对称签名。私钥签名,公钥验签。签发方拿私钥,谁要验证就拿公钥。公钥可以随便分发,私钥永远只在签发方手里。这意味着:第一,验证方无法伪造Token,它没有私钥;第二,即使某个验证方的公钥泄露,也不能被拿去做签名,因为没有私钥。
什么时候用哪个,我的建议非常简单直接:
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 单应用内部会话 | HS256 | 一套密钥,验证快,实现简单 |
| 多服务共享认证中心 | RS256 | 各服务只持公钥,认证中心持私钥,单点泄露影响面小 |
| 对外开放API签名 | RS256或ES256 | 第三方无需知道你的私钥,只需你下发公钥 |
| 高安全等级的多端体系 | 优先ES256 | 密钥更短,签名性能更好,但PHP端支持要看库版本 |
对比下来,RS256最大的优势不是"更安全"这么简单,而是它把"谁能签"和"谁能验"彻底分开了。在微服务架构里,如果所有服务都持同一个HS256密钥,那么任何一个服务被攻破,攻击者就能给整个体系签发合法Token。如果换RS256,某个验证服务被攻破,最多能验Token,不能签Token,危害范围小很多。
用Namshi/JOSE签发RS256和HS256的流程几乎一模一样,只是构造时的alg参数和sign()传入的密钥类型不一样。RS256时,sign()里传的必须是私钥内容,而不是字符串口令。
$jws = new JWS(['alg' => 'RS256']); $jws->setPayload(['uid' => 1001, 'exp' => time() + 3600]); $privateKey = file_get_contents('/path/to/private.pem'); $jws->sign($privateKey); $token = $jws->getTokenString();验证端用公钥:
$jws = JWS::load($token); $publicKey = file_get_contents('/path/to/public.pem'); if ($jws->verify($publicKey)) { // 通过 }这里最容易被坑的是密钥格式。PHP的OpenSSL扩展对PEM格式的支持是最稳定的,-----BEGIN PRIVATE KEY-----开头的那一坨。有人拿到PKCS#8格式的私钥,或者带-----BEGIN RSA PRIVATE KEY-----这种老式头,在部分环境下也能用,但跨版本兼容性就看你运气了。我自己踩过几次坑之后,统一用OpenSSL命令转换成PKCS#8无加密格式,省心。
3.2 JWT三段式结构里暗藏的细节
JWT字符串的三段结构,谁都知道。但深入看细节,能发现很多决定成败的编码规则。
第一段Header,存储算法和类型。第二段Payload,存业务数据。第三段是签名值。三者的关系是:对第一段和第二段分别做base64url编码,中间用点号拼接成字符串,然后用算法对拼接后的字符串做签名,再把签名结果也做base64url编码,作为第三段。
base64url编码和普通base64编码的区别是:标准base64里有+、/、=三个特殊字符,这些字符放在URL里会被转义或者被路由解析出歧义。所以JWT规范定义了base64url:把+换成-,把/换成_,去掉末尾的=填充。
这个转换看似小事,实际改起来能让人崩溃。你如果自己拼了一个标准base64串,直接塞到Token里,然后在某个环节用了严格的正则去校验Token字符集,马上就会报错。
还有一点值得注意:Header和Payload里的JSON是紧凑序列化,就是没有多余空白的JSON。你千万不要在Header里加个空格、换行,因为签名是对"原始字节"做的,不是对"解析后的JSON对象"做的。解析和重新序列化再签名,出来的签名永远对不上。这就是为什么NoSQL或者网关层不要试图"美化"JWT,原生什么样子就保持什么样子。
3.3 alg:none漏洞与算法白名单
这可能是JWT历史上最著名的安全事故之一。
早期的很多JWT库,默认支持alg字段为none。none意味着不签名,Header里写{"alg":"none"},然后Payload随便改,第三段直接置空,服务端如果没做算法白名单校验,验签直接通过。攻击者不需要知道任何密钥,只需要把Token的Header改成none,就能伪造任意身份。
Namshi/JOSE本身对none的支持情况和具体版本有关,但不要指望库帮你挡住所有问题。真正靠谱的做法是,在服务端验签之前,强制检查Header里的算法字段是否在你的白名单里。不要信任Token里声明的算法,要信任你代码里写死的算法。
$jws = JWS::load($token); $header = $jws->getHeader(); if (!isset($header['alg']) || !in_array($header['alg'], ['HS256', 'RS256'], true)) { throw new \RuntimeException('算法不在白名单内'); } // 再根据白名单选对应密钥做验证把校验写在你自己的验签逻辑里,而不是依赖库默认行为,这是我在生产上坚持的原则。一个系统如果只允许HS256,那么就算攻击者把算法头改成RS256、HS512或者其他任何值,你都不该去尝试用另一个算法密钥去验。直接拒绝是最干净的。
3.4 密钥长度与口令强度
HS256的密钥到底该多长?我的答案是:最少32字节,也就是256位,越长越好。
原因很简单:HMAC-SHA256的安全强度取决于密钥熵。如果你用一个"123456"当密钥,那不管算法多强,攻击者可以轻松暴力枚举出来。秘钥这东西是拼熵值的,不是拼长度。一个32字节的随机字符串,和一个32字节的"aaaaaaaa",安全性天差地别。
生成密钥的正确姿势:
openssl rand -base64 48然后在代码里配置这个随机串。不要把密钥硬编码在代码仓库里,环境变量或者配置中心都行。
对于RS256,私钥长度一般2048位起步,4096位也不算过分。生成密钥:
openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem4. JWE加密型Token:怎么把载荷藏起来
4.1 JWT和JWE的关系再澄清一遍
严格来说,JWT可以有两种形态:一种是JWS形态,也就是签名型JWT,载荷是base64url编码的明文,任何拿到Token的人都能直接解码出Payload。另一种是JWE形态,也就是加密型JWT,载荷经过加密,没有密钥的人只能看到一堆密文。
这个差别非常重要。很多业务场景里,Token里存的可能是手机号、邮箱、用户角色这类敏感信息。签名只能保证"没被改过",不能保证"别人看不见"。只要有人能截获或者复制Token,base64一解,什么隐私都暴露了。我在对接第三方平台时就遇到过这种需求,他们要求在Token里返回用户手机号,但又不希望中间链路能看到明文。
这种场景下,JWE才是正确选择。JWE不负责防篡改,它负责的是"把内容藏起来"。当然,实际使用中JWE往往也会结合签名机制一起用,或者使用带AEAD特性的加密算法同时保证机密性和完整性。
4.2 JWE的五段式结构
JWE和JWS的序列化结构差异很大。JWS是三段,JWE是五段,用四个点分隔:
header.encryptedKey.iv.ciphertext.tag- header:加密算法参数,标明密钥加密算法和内容加密算法
- encryptedKey:被加密后的内容加密密钥(CEK)
- iv:初始化向量
- ciphertext:真正的密文
- tag:认证标签,用于完整性校验
理解这个结构有个通俗的类比:想象你要快递一个包裹。内容加密密钥(CEK)相当于一把临时锁的钥匙,你先用这把钥匙加密包裹里的东西;然后再用收件人的公钥把这把临时钥匙本身加密。收件人先用自己的私钥解出临时钥匙,再用临时钥匙解出包裹。这就是JWE里"密钥加密"和"内容加密"分离的基本思路。
JWE规范里,密钥加密算法常见有RSA-OAEP、RSA1_5、A128KW、A256KW、ECDH-ES等。内容加密算法常见有A128CBC-HS256、A256CBC-HS512、A128GCM、A256GCM等。取一个组合示例:RSA-OAEP负责加密CEK,A256GCM负责加密Payload。
4.3 在PHP里怎么落地JWE
这就要说实话了。Namshi/JOSE这个库对JWE的支持一直是比较初级的,远没有它的JWS功能成熟。很多版本里,JWE相关实现更像是规范验证性质的东西,生产环境直接拿去用,我心里没底。
如果你需要JWE生产能力,我的建议有两个方向:
第一个方向,直接换成专门做JOSE的现代库。目前PHP生态里比较成熟的是web-token/jwt-framework。它的Composer包拆得很细,支持各种JWE算法组合,而且和PHP 7.4、8.x的兼容性都要好很多。
第二个方向,如果项目确实依赖Namshi/JOSE,那么JWE部分只处理简单的RSA-OAEP组合,并且必须写充分的单元测试,用官方的RFC 7516测试向量验证加解密结果。不要凭空相信一个旧库的加密实现。
下面这段是我用web-token/jwt-framework实现JWE加密的参考思路,不是Namshi/JOSE的代码,但它展示的是JWE实现的标准路径:
use Jose\Component\Core\AlgorithmManager; use Jose\Component\Core\JWK; use Jose\Component\Encryption\Compression\DeflateCompression; use Jose\Component\Encryption\JWEDecrypter; use Jose\Component\Encryption\JWEBuilder; use Jose\Component\Encryption\Serializer\CompactSerializer; // 构造算法管理器:密钥加密用RSA-OAEP,内容加密用A256GCM $algorithmManager = AlgorithmManager::create([ new \Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP(), new \Jose\Component\Encryption\Algorithm\ContentEncryption\A256GCM(), ]); // 接收者的公钥 $recipientKey = new JWK([ 'kty' => 'RSA', 'n' => '...', 'e' => 'AQAB', ]); $jweBuilder = new JWEBuilder($algorithmManager, new DeflateCompression()); $jwe = $jweBuilder ->create() ->withPayload(json_encode(['uid' => 1001, 'mobile' => '138xxxx1234'])) ->withSharedProtectedHeader(['alg' => 'RSA-OAEP', 'enc' => 'A256GCM']) ->addRecipient($recipientKey) ->build(); $serializer = new CompactSerializer(); $token = $serializer->serialize($jwe, 0);解密端思路刚好反过来:用私钥构造JWK,定义对应的算法管理器,反序列化Token,调用JWEDecrypter解密,拿到Payload。
这种以"接收者公钥加密、接收者私钥解密"的模型,和RSA非对称签名的方向是反着的。签名是私钥签、公钥验;加密是公钥加密、私钥解密。很多开发者在这里绕晕,我提一句:签名解决"是谁写的",加密解决"谁能看"。方向千万别搞混。
5. JWK密钥格式与多端协作实战
5.1 JWK到底是个什么东西
JWK全称JSON Web Key,就是用JSON格式来描述和交换密钥。它最大价值在于:解决了"密钥在不同系统间传输时格式不统一"的问题。
PEM格式的RSA公钥在不同语言、不同框架里还好,基本都认。但JWK把公钥拆成你一眼能看懂的JSON字段:kty标识算法族,n是RSA模数,e是公钥指数,kid是密钥ID,方便你在多密钥环境里识别用哪把钥匙。有了JWK,PHP、Java、Go、Node之间协商密钥就简单多了,互相传入一个JSON即可。
Namshi/JOSE的签名方法本身可以直接用JWK格式的数组或者JSON字符串作为密钥参数。这意味着你不一定要拿传统PEM字符串,也可以拿一个现成的JWK对象去签名或验签。
5.2 PEM与JWK互转
生产中最常见的需求是:我有一个PEM文件,但对接系统要求提供JWK。互转用OpenSSL命令就能完成。
从RSA公钥PEM生成JWK,可以先借助命令行工具,或者用PHP侧的openssl_pkey_get_details函数拿到关键参数,再合成对应JSON。我的做法是直接用一条命令导出:
openssl rsa -in private.pem -pubout -outform DER | openssl base64 -A完整的JWK转换,如果只是内部使用,也可以借助firebase/php-jwt里现成的JWK类静态方法:
$publicKey = file_get_contents('public.pem'); $jwk = \Firebase\JWT\JWK::parseKey(['kty' => 'RSA', 'n' => '...', 'e' => '...']);但要注意,不同库对JWK字段的解析兼容性并不完全一致。常见问题就是n和e的base64url编码格式、是否带填充、大小写是否规范。换一个库就解析失败的事,我遇到不止一次。
5.3 多密钥环境下的kid管理与密钥轮换
当你有多个公钥同时在使用,比如新旧密钥同时存在,验证端需要一个机制来判断"这个Token到底是用哪把钥匙签的"。这个机制就是kid。
签发Token时,在Header里声明kid:
$jws = new JWS(['alg' => 'RS256', 'kid' => 'key-2024-01']);验证端拿到Token后,先读Header里的kid,再去密钥库里找到对应公钥做验签。这样新老密钥可以并存,老的Token在老密钥到期前仍然有效,新的Token用新密钥签发,平滑过渡。
密钥轮换周期,我的建议是内部系统半年到一年轮换一次,对外开放平台至少按季度规划。轮换时不要删旧密钥,至少保留两个生命周期重叠的周期,避免老Token在用户还没刷新时突然全部失效。
6. 实战中的常见坑与调试技巧
6.1 Token在HTTP传输中的表现
JWT最常见的传输方式就是放在HTTP头里:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...这里要注意:Bearer和一个空格是标准格式。有些客户端会把Token直接放在Authorization: Token xxx或者自定义头里,都能用,但你要和服务端约定一致。我见过最典型的报错,就是客户端发成了Authorization: Bearer后面直接跟Token但没有空格,服务端解析字符串时把Bearer当成Token的前缀,验签必然失败。
另一个容易出现的问题是,某些框架的中间件会吞掉Authorization头,或者因为PHP运行在CGI模式下没有把HTTP头完整转发。排查思路很简单:先打印$_SERVER['HTTP_AUTHORIZATION']看有没有值,没有就查Web服务器的头转发配置。
6.2 时钟偏差与过期时间校验
分布式环境下,签发Token的机器和验证Token的机器,时钟不可能完全一致。如果签发机设置exp为当前时间加5分钟,验证机时钟快了10秒,那么Token在验证方看起来就已经过期了。这个在单机开发时根本不会暴露,一上分布式集群就全冒出来。
解决方案有两种:一是业务侧校验过期时间时允许一定的时钟偏差窗口,比如5分钟;二是在系统层面做NTP时间同步。
我用Namshi/JOSE时,过期时间从来不在库里自动校验,都是自己从Payload取出来对比。实现不复杂:
$payload = $jws->getPayload(); if (isset($payload['exp']) && time() > $payload['exp'] + 300) { throw new \RuntimeException('Token已过期'); }这个300秒就是时钟偏差容忍窗口。你根据实际场景自己调,偏差越大窗口越大,安全性相应下降。
6.3 调试与排错三板斧
第一板斧:把Token在jwt.io这类调试工具里解码,看Header和Payload内容是否符合预期。但注意不要把生产密钥输上去,这类工具跑在浏览器里,密钥一旦泄露就等于把签名能力交给了别人。我只会用它们看非对称公钥场景下的结构,或者自己本地起工具调试。
第二板斧:自己写一个小的base64url解码脚本,把三段分别解出来,一步一步验证Header、Payload、签名是否正确。这样你能快速定位问题到底出在生成阶段还是验证阶段。
第三板斧:检查密钥内容是否一致。这是最隐蔽的坑。两个环境里的密钥字符串看着一样,但一个末尾多了一个换行符,验签就直接失败。比较密钥时用二进制比较,别用肉眼看。你在Linux下可以这样确认:
md5sum private.pem两个环境比对一下哈希值,一眼就知道密钥是否一致。
6.4 性能注意事项
JWT验签涉及非对称算法运算,RSA验签比HMAC慢不少。RS256单次验签在普通服务器上大概是零点几毫秒到一两毫秒的量级,高并发场景下这个开销不能忽略。优化思路无非这么几条:能缓存验签结果就缓存,Cache Key用Token的哈希;能用HS256的内部服务没必要都换成RS256;连接池、OpCache这些常规性能手段也别落下。
实际压测时,瓶颈往往不是JWT验签本身,反而是日志打印、数据库查询这些业务代码。我见过一个项目每次请求都打印完整Token,日志文件一天膨胀几个GB,费磁盘不说,Token里含敏感信息还跟着日志一起泄露。这种细节比算法选型更容易埋雷。
7. JOSE签名加密的后续扩展思路
最后再说点我实际操作中的体会。只学会用Namshi/JOSE签Token、验Token,只是第一步。真正要用好这套体系,你得把JWS、JWE、JWK当作一套完整的互操作标准理解,而不是某个库的API。
后续可以做这么几个扩展:
一是把JWT接入你现有的API网关,做统一认证。网关只验签不解析业务字段,业务服务从网关透传过来的Header里取身份信息。这种模式下RS256几乎是标配。
二是做刷新令牌体系。短时Token配合长时Refresh Token,Refresh Token既可以用JWS签名,也可以用JWE加密存储更多信息。目前很多认证系统就是这么设计的。
三是研究一下ES256。它在密钥长度和性能上优于RS256,PHP端支持也已经有了,未来在移动端算力受限的场景会越来越有优势。
四是关注JOSE生态的新进展。关于算法混淆、密钥混淆这类针对JWT实现的安全攻击,每隔一两年就会有新的研究出现。作为后端开发者,别只当API调用方,把RFC 7515、RFC 7516、RFC 7517这三篇规范抽时间看一遍,很多坑你根本不用亲身去踩就能避开。