- 认证鉴权
- 后端
【免费下载链接】jwt
A simple library to work with JSON Web Token and JSON Web Signature
密钥轮换(Key Rotation)是 JWT 签发系统必须面对的工程命题:长期使用同一把密钥签名,既会放大密钥泄露的破坏范围,也限制了算法升级的空间。本文以lcobucci/jwt库为核心,从"为什么要轮换"到"如何无痛轮换"展开,完整演示从HS256迁移到BLAKE2B的实战过程,并深入剖析SignedWithOneInSet与SignedWithUntilDate两个验证约束的源码实现原理。读完本文,你将掌握一套可复制、可落地的向后兼容密钥轮换方案,让旧密钥自然过期、新密钥平滑接管,用户全程无感。
什么是密钥轮换?为什么要定期轮换?
密钥轮换(Key Rotation),本质上是定期将旧的加密密钥退役,并用新密钥将其替换。在 lcobucci/jwt 所实现的 JWT/JWS 生态中,这意味着:用于给 Token 签名、以及用于校验 Token 签名的密钥,都会在一个受控的时间窗口内进行新旧交替。
对签发系统而言,定期执行密钥轮换是行业标准操作(industry standard),其收益体现在三个方面:
- 限制同一把密钥签发的 Token 数量,降低密码分析(cryptanalysis)攻击的成功率。攻击者掌握的有效签名样本越多,越有机会从统计角度逼近密钥本身;轮换等于主动给密码分析"断粮"。
- 获得采纳其他算法或更强密钥的机会。比如从
HS256升级到BLAKE2B,或从 1024 位 RSA 密钥升级到 4096 位,这都需要以轮换为载体的"换钥仪式"。 - 限制密钥泄露(compromised keys)造成的破坏范围。即便某把密钥意外泄露,只要它已退役,攻击者也只能伪造"过去"的 Token,而无法影响"未来"的签发体系。
轮换的真正挑战:硬切换(Hard Cut)会让旧 Token 全部失效
轮换本身并不难,难的是轮换完成之后的那段过渡期。
想象一个典型场景:应用在某天完成了密钥轮换,签发逻辑立刻切换到新密钥。但此时,所有仍在使用中的旧 Token——那些在轮换前签发、尚未过期、仍然有效的 Token——在"硬切换"(hard cut)模式下会立刻校验失败。
原因很简单:旧 Token 是用旧密钥签名的,而验证端只认新密钥,签名自然对不上。
想象一下,你恰恰是在某次密钥轮换之前刚刚登录的那个用户,那么轮换完成后,你几乎肯定会被迫重新登录一次。这体验相当糟糕,对吧?
这正是密钥轮换最需要被"设计"而非"执行"的地方:轮换必须对存量用户向后兼容,让旧 Token 在自然过期之前依然可用。
轮换前的基线:用 HS256 签发与验证 Token
在引入平滑轮换之前,先建立一个基线场景:应用使用对称算法HS256,配合一把密钥签发 Token。签发端代码如下(取自 docs/rotating-keys.md 的完整示例):
<?php declare(strict_types=1); namespace MyApp; require 'vendor/autoload.php'; use DateTimeImmutable; use Lcobucci\Clock\FrozenClock; use Lcobucci\JWT\Builder; use Lcobucci\JWT\JwtFacade; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Key\InMemory; // `FrozenClock` 用于把时间固定在某一点,从而让后续验证能够稳定通过 $clock = new FrozenClock(new DateTimeImmutable('2023-11-04 21:06:01+00:00')); $token = (new JwtFacade(clock: $clock))->issue( new Signer\Hmac\Sha256(), InMemory::plainText( 'a-very-long-and-secure-key-that-should-actually-be-something-else' ), static fn (Builder $builder): Builder => $builder ->issuedBy('https://api.my-awesome-app.io') ->permittedFor('https://client-app.io') );几个值得注意的细节:
JwtFacade会自动补齐三个时间声明。查看 JwtFacade.php 的issue()实现可以发现,它会在回调之前自动写入iat(签发时间)、nbf(生效时间)和exp(过期时间,默认在当前时间上加 5 分钟),这也是后文验证能通过的前提。FrozenClock来自lcobucci/clock,用于把系统时钟冻结在一个确定的时间点,让示例可复现。composer.json中lcobucci/clock被列为建议安装(suggest)的依赖(见 composer.json)。InMemory::plainText()直接以明文形式加载密钥内容,密钥不允许为空字符串(空密钥会抛出InvalidKeyProvided::cannotBeEmpty(),见 InMemory.php)。
对应的解析与验证逻辑如下,使用SignedWith约束校验"必须是这把密钥、这个算法签的",再用StrictValidAt约束校验iat/nbf/exp三个时间声明:
<?php declare(strict_types=1); namespace MyApp; require 'vendor/autoload.php'; use DateTimeImmutable; use Lcobucci\Clock\FrozenClock; use Lcobucci\JWT\JwtFacade; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Validation\Constraint; // `FrozenClock` 用于把时间固定在某一点,从而让后续验证能够稳定通过 $clock = new FrozenClock(new DateTimeImmutable('2023-11-04 21:06:35+00:00')); $validationConstraints = [ new Constraint\SignedWith( new Signer\Hmac\Sha256(), InMemory::plainText( 'a-very-long-and-secure-key-that-should-actually-be-something-else' ), ), new Constraint\StrictValidAt($clock), ]; $jwt = ''; // 例如从请求头中取出 $token = (new JwtFacade())->parse($jwt, ...$validationConstraints);注意JwtFacade::parse()的签名约束:它要求至少传入一个SignedWith(签名验证)和一个ValidAt(时间验证)约束,其余约束通过变长参数传入(见 JwtFacade.php)。
若想在本机验证这段逻辑,可以用文档中给出的样例 Token(为可读性已加入换行):
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 .eyJpYXQiOjE2OTkxMzE5NjEsIm5iZiI6MTY5OTEzMTk2MSwiZXhwIjoxNjk5MTMyMjYxLCJpc3MiOiJ odHRwczovL2FwaS5teS1hd2Vzb21lLWFwcC5pbyIsImF1ZCI6Imh0dHBzOi8vY2xpZW50LWFwcC5pbyJ9 .IA9S0n8Q2O97lyR8KczVE8g-hxbbH6_TfJS-JWTQR4c平滑轮换实操:从 HS256 无缝迁移到 BLAKE2B
现在进入本文的核心场景:假设我们要把签发算法升级为新的对称算法BLAKE2B,同时不允许任何未过期的旧 Token 失效。
第一步:修改签发逻辑(只是普通轮换)
签发端的改动非常直接——替换签名器与密钥即可。BLAKE2B的密钥是一段 Base64 编码的原始字节,因此需要使用InMemory::base64Encoded()而非plainText()加载:
<?php declare(strict_types=1); namespace MyApp; require 'vendor/autoload.php'; use DateTimeImmutable; use Lcobucci\Clock\FrozenClock; use Lcobucci\JWT\Builder; use Lcobucci\JWT\JwtFacade; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Key\InMemory; // `FrozenClock` 用于把时间固定在某一点,从而让后续验证能够稳定通过 $clock = new FrozenClock(new DateTimeImmutable('2023-11-04 21:06:01+00:00')); $token = (new JwtFacade(clock: $clock))->issue( - new Signer\Hmac\Sha256(), + new Signer\Blake2b(), - InMemory::plainText( - 'a-very-long-and-secure-key-that-should-actually-be-something-else' + InMemory::base64Encoded( + 'GOu4rLyVCBxmxP+sbniU68ojAja5PkRdvv7vNvBCqDQ=' ), static fn (Builder $builder): Builder => $builder ->issuedBy('https://api.my-awesome-app.io') ->permittedFor('https://client-app.io') );这里补充两点源码层面的背景:
Blake2b签名器对密钥长度有硬性要求:查看 Blake2b.php,其MINIMUM_KEY_LENGTH_IN_BITS = 256,即密钥原始字节长度必须达到 32 字节(256 位),否则在签名阶段就会抛出InvalidKeyProvided::tooShort()。sign()内部基于sodium_crypto_generichash()实现,verify()则使用常量时间比较函数hash_equals()防止时序攻击。InMemory::base64Encoded()内部会先解码再使用(见 InMemory.php),所以传入的必须是合法的 Base64 字符串。
该代码签发出的新 Token 样例(换行为可读性添加):
eyJ0eXAiOiJKV1QiLCJhbGciOiJCTEFLRTJCIn0 .eyJpYXQiOjE2OTkxMzE5NjEsIm5iZiI6MTY5OTEzMTk2MSwiZXhwIjoxNjk5MTMyMjYxLCJpc3Mi OiJodHRwczovL2FwaS5teS1hd2Vzb21lLWFwcC5pbyIsImF1ZCI6Imh0dHBzOi8vY2xpZW50LWFwc C5pbyJ9.bD67s8IXpAJiBTIZn1et_M5WSS7kfmuNiacNRz5lArQ到目前为止,这与普通轮换没有任何区别。真正的关键在验证端。
第二步:修改验证逻辑(向后兼容的关键)
验证端的改动是核心:把单一的SignedWith约束替换为SignedWithOneInSet,并在其内部按优先级嵌套多个SignedWithUntilDate约束。每个SignedWithUntilDate都对应一把"有明确退役日期"的密钥:
<?php declare(strict_types=1); namespace MyApp; require 'vendor/autoload.php'; use DateTimeImmutable; use Lcobucci\Clock\FrozenClock; use Lcobucci\JWT\JwtFacade; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Validation\Constraint; // `FrozenClock` 用于把时间固定在某一点,从而让后续验证能够稳定通过 $clock = new FrozenClock(new DateTimeImmutable('2023-11-04 21:06:35+00:00')); $validationConstraints = [ - new Constraint\SignedWith( - new Signer\Hmac\Sha256(), - InMemory::plainText( - 'a-very-long-and-secure-key-that-should-actually-be-something-else' - ), - ), + new Constraint\SignedWithOneInSet( + new Constraint\SignedWithUntilDate( + new Signer\Blake2b(), + InMemory::base64Encoded( + 'GOu4rLyVCBxmxP+sbniU68ojAja5PkRdvv7vNvBCqDQ=' + ), + new DateTimeImmutable('2025-12-31 23:59:59+00:00'), + $clock, + ), + new Constraint\SignedWithUntilDate( + new Signer\Hmac\Sha256(), + InMemory::plainText( + 'a-very-long-and-secure-key-that-should-actually-be-something-else' + ), + new DateTimeImmutable('2023-12-31 23:59:59+00:00'), + $clock, + ), + ), new Constraint\StrictValidAt($clock), ]; $jwt = ''; // 例如从请求头中取出 $token = (new JwtFacade())->parse($jwt, ...$validationConstraints);完成上述改动后,应用现在能够同时接受新旧两种密钥签发的未过期 Token:
- 新密钥(
BLAKE2B)签发的 Token,其验证约束有效期到2025-12-31 23:59:59+00:00; - 旧密钥(
HS256)签发的 Token,其验证约束自动在2023-12-31 23:59:59+00:00到期——即使工程师忘记手动把旧密钥从清单里删除,到了这个时间点,旧密钥也会因为约束过期而自然失效,无法再通过验证。
也就是说,"密钥退役"这件事被直接编码进了验证逻辑里,而不是依赖运维人员记得去改代码。
原理剖析:三个约束如何协作实现平滑轮换
平滑轮换的魔法来自SignedWithOneInSet、SignedWithUntilDate、SignedWith三个约束的层层委托。理解它们各自的职责,才能正确配置自己的轮换方案。
SignedWithOneInSet:按优先级逐个尝试,"任一通过即放行"
查看 SignedWithOneInSet.php 的完整实现:
final readonly class SignedWithOneInSet implements SignedWithInterface { /** @var array<SignedWithUntilDate> */ private array $constraints; public function __construct(SignedWithUntilDate ...$constraints) { $this->constraints = $constraints; } public function assert(Token $token): void { $errorMessage = 'It was not possible to verify the signature of the token, reasons:'; foreach ($this->constraints as $constraint) { try { $constraint->assert($token); return; } catch (ConstraintViolation $violation) { $errorMessage .= PHP_EOL . '- ' . $violation->getMessage(); } } throw ConstraintViolation::error($errorMessage, $this); } }它的核心语义是"集合内任一约束验证通过即整体通过":按构造时传入的顺序逐个执行assert(),一旦某个约束成功就直接返回;只有当所有约束都失败时,才抛出聚合了全部失败原因的ConstraintViolation。
注意:SignedWithOneInSet的构造参数类型被限定为SignedWithUntilDate(SignedWithUntilDate ...$constraints),这意味着它天然只用于"带过期时间的签名验证"这一场景。
SignedWithUntilDate:带退役日期的签名验证
查看 SignedWithUntilDate.php 的实现,它内部做了两件事:
public function assert(Token $token): void { if ($this->validUntil < $this->clock->now()) { throw ConstraintViolation::error( 'This constraint was only usable until ' . $this->validUntil->format(DateTimeInterface::RFC3339), $this, ); } $this->verifySignature->assert($token); }- 先做时间闸门:如果当前时间已超过
validUntil(即约束的"退役日期"),直接抛出ConstraintViolation,根本不会去碰签名——这就是"旧密钥到点自动失效"的机制来源。其构造函数接受Signer、Signer\Key、DateTimeImmutable $validUntil三个必填参数,ClockInterface $clock为可选参数(不传时默认使用系统真实时钟,见 SignedWithUntilDate.php)。 - 再委托真正的签名验证:时间闸门通过后,把验证工作委托给内部创建的
SignedWith实例完成。
SignedWith:最底层的"签名/算法/密钥"三重校验
查看 SignedWith.php,底层的SignedWith依次完成:
- Token 类型检查:Token 必须是
UnencryptedToken(非加密的普通 JWT),否则报错You should pass a plain token; - 算法匹配检查:Token 头部
alg必须与签名器声明的algorithmId()一致,否则报错Token signer mismatch; - 签名校验:调用
$this->signer->verify()用指定密钥验证签名,失败报错Token signature mismatch。
从测试用例看行为约定
仓库的单元测试进一步印证了上述协作逻辑(见 SignedWithOneInSetTest.php):
- 当所有
SignedWithUntilDate约束都失败时,抛出的异常消息会聚合所有失败原因,例如同时包含Token signature mismatch与This constraint was only usable until ...; - 只要任意一个约束成功(哪怕其他约束全部失败),
assert()就静默通过——这正是平滑轮换的验证基础。
约束顺序为什么重要?
文档中特别强调了一条容易被忽略的规则:
SignedWithUntilDate约束在SignedWithOneInSet中的顺序是有意义的,强烈建议把旧密钥放在列表末尾。
原因结合源码很好理解:SignedWithOneInSet会按顺序逐个尝试约束,并且在第一个成功的约束处短路返回。把新密钥放在最前面,意味着:
- 绝大多数新签发的 Token 在第一次尝试时就通过验证,不需要遍历整张密钥列表,验证效率最高;
- 旧密钥约束排在后面,只有在"新密钥验证失败"时才会被触及,扮演兜底角色;
- 顺序同时也是一种隐性的优先级声明:最信任、最优先的密钥放最前。
如果你的应用有不止两代密钥(例如同时存在 v1 / v2 / v3 三代),也应该按照"最新 → 最旧"的顺序排列,并给每一代设置各自的退役日期。
完整轮换方案的时间线设计
把上述代码组合起来,一个可落地的向后兼容轮换方案是这样的时间线:
| 阶段 | 签发端 | 验证端 | 效果 |
|---|---|---|---|
| 轮换前 | HS256+ 旧密钥 | SignedWith(仅旧密钥) | 全部 Token 用旧密钥 |
| 轮换日 | BLAKE2B+ 新密钥 | SignedWithOneInSet(新密钥在前、旧密钥在后) | 新旧 Token 同时有效,存量用户无感 |
旧密钥退役日(2023-12-31之后) | 维持BLAKE2B | SignedWithOneInSet中旧约束自动失效 | 旧 Token 自然过期,旧密钥即使留在清单里也无法通过验证 |
新密钥最终退役日(2025-12-31之后) | 按需再轮换 | 同样机制再次滚动 | 循环往复 |
可以看到,一旦建立了"验证约束内置退役日期"的机制,后续每一轮轮换都只需重复同一个模式:签发端换新签名器/新密钥,验证端在SignedWithOneInSet列表头部追加新约束并设置退役日期。
实践建议与注意事项
结合源码与文档,最后给出几条实战层面的建议:
- 对称密钥轮换与非对称密钥同样适用:本文示例用的是对称算法(
HS256→BLAKE2B),但SignedWithOneInSet/SignedWithUntilDate对 RSA、ECDSA、EdDSA 等非对称算法同样有效(它们都实现自Signer接口),只需把InMemory::plainText()换成InMemory::file()加载 PEM 密钥文件(见 InMemory.php)。 - 不要把密钥硬编码在代码里:示例为演示需要使用了
InMemory::plainText()与InMemory::base64Encoded(),生产环境应改用InMemory::file()从受保护的路径加载密钥,并配合SensitiveParameter属性在堆栈跟踪中隐藏密钥内容。 - 注意
StrictValidAt的角色:签名验证约束只回答"这是谁签的",时间有效性由StrictValidAt(严格校验iat/nbf/exp三个声明,见 StrictValidAt.php)负责。轮换示例中两者始终搭配使用,缺一不可。若你的场景需要容忍时钟偏差,可参考 LooseValidAt 或给StrictValidAt传入 leeway 参数。 - 退役日期要留足缓冲:旧密钥的
validUntil应覆盖"所有已签发 Token 的最大过期时间",否则未过期的旧 Token 会提前失效。示例中旧密钥退役日2023-12-31明显晚于 Token 签发时间2023-11-04加上默认 5 分钟有效期,正是这个道理。 - 环境要求:本仓库要求 PHP
~8.4.0 || ~8.5.0,并依赖ext-openssl、ext-sodium与psr/clock ^1.0(见 composer.json);使用Blake2b签名器依赖ext-sodium扩展,部署前务必确认已启用。
密钥轮换不是一次性的运维事件,而应当被设计成应用代码的一部分。借助SignedWithOneInSet与SignedWithUntilDate,lcobucci/jwt把"旧密钥自动退役"变成了可声明的、有序的、可测试的验证逻辑——这正是本文想传达的核心工程思想。
- 认证鉴权
- 后端
【免费下载链接】jwt
A simple library to work with JSON Web Token and JSON Web Signature
相关推荐
CANN/catlass MLA算子示例
MLA Example Readme Code Organization ├── 19_mla │ ├── CMakeLists.txt CMake build
算子库人工智能深度学习高性能计算CANNAscendJWT签名密钥轮换自动化:tymon/jwt-auth方案
JWT签名密钥轮换自动化:tymon/jwt auth方案 你是否曾因JWT签名密钥泄露而面临系统安全风险?是否在手动轮换密钥时遭遇服务中断?本文将详细介绍如何
认证鉴权后端安全无缝升级!MediaMTX中JWT认证密钥轮换机制全解析
无缝升级!MediaMTX中JWT认证密钥轮换机制全解析 在实时流媒体服务中,安全认证是保护内容不被未授权访问的关键环节。JSON Web Token(JWT,
音视频后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考