前言
JWT(JSON Web Token)验证失败的症状很统一:接口一律返回 401,日志里只有一句"token 无效"或者干脆什么都没有。麻烦的是原因太多——可能是签名算法不匹配、可能是密钥被读错、可能是请求头根本没传到 PHP、也可能是服务器时钟偏了几十秒。而不同的库抛出的异常信息往往很含糊,SignatureInvalidException和"头部缺失"在业务日志里长得一样。
先澄清标题里的一处表述:JWT 不是 PHP 语言或某个扩展的功能,它完全由用户态库实现(PHP 生态里常见的是firebase/php-jwt和lcobucci/jwt)。所以并不存在"PHP 8.5 的 JWT 配置",PHP 8.5 在这里只是运行版本,验证逻辑与 8.0~8.4 完全一致。本文按 8.5 环境讲,所有结论对 PHP 8.x 通用。
下文按"从请求到校验"的顺序把链路拆成五段,每段给出可自查的方法,并附一份不依赖任何库、纯 PHP 实现的 HS256 校验脚本——它能在不装 composer 包的情况下帮你确认"到底是签名错还是环境错"。示例最低要求 PHP 8.0。
一、把失败分成五段,逐段排除
JWT 校验的链路不长,但每段的失败表现都可能是"401"。先把它们分开:
| 环节 | 典型故障 | 表现 | 自查方法 |
|---|---|---|---|
| 1. 请求头传输 | Apache/Nginx 没有把Authorization透传给 PHP | $_SERVER里找不到该头 | var_dump(array_keys($_SERVER))找HTTP_AUTHORIZATION |
| 2. 令牌解析 | 用了标准 base64 而非 base64url | 三段里某段解不出来 | 手工解码 payload 看是否是合法 JSON |
| 3. 密钥来源 | .env里的密钥被截断、带引号、或做过 base64 编码 | 签名永远不匹配 | 打印密钥长度和哈希,与签发端对比 |
| 4. 算法与库 API | 库大版本升级后decode()签名变化 | 直接抛参数错误 | 对照所用库的版本与签名 |
| 5. 时间声明 | exp/nbf/iat校验零容差,服务器时钟偏移 | 时好时坏,重启后短暂恢复 | 对比服务器时间与签发服务器时间 |
经验上,第 1 段和第 3 段占了绝大多数,而这两段恰恰是日志里最不显眼的:一个表现为"没有 token",一个表现为"签名不匹配"。
二、第 1 段:Authorization 头常常根本没到 PHP
这是最经典的坑。Apache 出于历史原因,默认不会把Authorization请求头放进 CGI 环境变量,于是$_SERVER['HTTP_AUTHORIZATION']不存在;PHP-FPM 场景下只要 Nginx 的fastcgi_params里漏了对应条目,结果一样。表现就是:前端明明带了 token,后端却报"未提供令牌"。
Apache 侧的两种修法:
# 方式一:Apache 2.4.13+ 可用,让 Authorization 直接进 CGI 环境 CGIPassAuth On # 方式二:老版本用 SetEnvIf 手工搬运 SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1Nginx + PHP-FPM 侧:
location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php8.5-fpm.sock; # 关键:把 Authorization 头显式传给 PHP fastcgi_param HTTP_AUTHORIZATION $http_authorization; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }还有一个容易漏的情况:Apache 在重写(rewrite)之后,头可能出现在REDIRECT_HTTP_AUTHORIZATION里。所以排查时不要把查找范围局限在一个键名上:
<?php declare(strict_types=1); /** 兼容多种部署环境的取值方式 */ function bearerToken(): ?string { // getallheaders() 在 PHP 7.3 起 FPM 环境也可用,但仍要兜底 $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? null; if ($header === null && function_exists('getallheaders')) { foreach (getallheaders() as $name => $value) { if (strcasecmp($name, 'Authorization') === 0) { $header = $value; break; } } } if ($header === null) { return null; } if (!str_starts_with($header, 'Bearer ')) { // str_starts_with 是 PHP 8.0 的 return null; } return trim(substr($header, 7)); }另外,如果接口是跨域调用的,浏览器在发送带Authorization头的请求之前会先发一个OPTIONS预检(preflight)请求。如果后端没有正确响应预检,真实请求根本不会发出,后端日志里连 401 都看不到,只有一堆 OPTIONS。这一点经常被误判为"token 无效"。
三、第 3 段:密钥被"读进来时就错了"
firebase/php-jwt等库把密钥当作原始字节处理。这意味着两件事:
其一,.env文件里的#会被当成注释。如果密钥是随机生成的、恰好含有#,Dotenv 解析时会把它后面的内容整段丢掉,得到的是被截断的密钥:
# ❌ 实际读到的值是 "abc",# 之后全部被当注释丢弃 JWT_SECRET=abc#def123 # ✅ 含特殊字符的值必须加引号 JWT_SECRET="abc#def123"同理,值两端的空格在未加引号时也容易被解析器 trim 掉,导致签发端与验证端拿到不同的密钥。
其二,密钥如果做过 base64 编码,必须先解回来。很多人用base64_encode(random_bytes(32))生成密钥存进配置,然后在验证时直接把那个 base64 字符串当密钥用——这个字符串的长度是 44 或 43 个字符,而原始密钥只有 32 字节。两边不一致,签名必然失败。正确做法是存储时就决定"存的是原始字节还是编码后的文本",然后在两边保持同一个约定:
<?php declare(strict_types=1); // 存储的是 base64 文本时,使用时必须解码 $secret = base64_decode((string) getenv('JWT_SECRET_B64'), true); if ($secret === false || strlen($secret) < 32) { throw new RuntimeException('JWT 密钥未正确配置'); }排查时有个笨办法特别有效:在签发端和验证端各打印一次strlen($secret)和hash('sha256', $secret)的前 8 位。两边不一致就说明是配置读取的问题,跟 JWT 逻辑无关。
四、第 2、4、5 段:编码、库 API 与时间
编码:JWT 用的是 base64url(Base64 URL 安全变体),它把标准 Base64 的+换成-、/换成_,并且去掉末尾的=填充。用base64_decode()直接解 JWT 的段,遇到-_或长度不是 4 的倍数就会失败。反之,自己拼 token 时如果用标准 base64,签发出去的 token 在别的库那边也验不过。
库 API:firebase/php-jwt在 6.0 版本改过decode()的签名——5.x 是decode($token, $key, array $allowedAlgs),6.x 起第三个参数不再是算法白名单,密钥要用Key对象包装。从 5.x 升到 6.x 的项目,如果代码没同步改,会出现"以前能过、升级后全 401"的现象。同理lcobucci/jwt从 3.x 到 4.x/5.x 也重构过配置方式。
时间:exp(过期时间)、nbf(生效时间)、iat(签发时间)都是相对服务器时钟判断的。多台服务器之间时钟偏差 30 秒以上,就会出现"刚签发就过期"或"还没生效"。另外库默认的容差通常是 0,需要显式给一点余量(firebase/php-jwt通过JWT::$leeway设置)。
五、实战:不依赖任何库的 HS256 校验脚本
下面这份脚本可以在不安装 composer 包的情况下验证"签名对不对、编码对不对",非常适合放在排查的第一步:
<?php declare(strict_types=1); // 最低要求:PHP 8.0 // 运行:php jwt_check.php /** * base64url 编码:去掉填充,替换 +/ 为 -_ */ function b64url_encode(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } /** * base64url 解码:补回填充,还原字符表,并对非法输入抛错 */ function b64url_decode(string $b64): string { $normalized = strtr($b64, '-_', '+/'); $remainder = strlen($normalized) % 4; if ($remainder > 0) { $normalized .= str_repeat('=', 4 - $remainder); } $bin = base64_decode($normalized, true); // strict 模式,拒绝非法字符 if ($bin === false) { throw new InvalidArgumentException('非法的 base64url 片段'); } return $bin; } /** 用 HS256 签发 */ function sign(array $claims, string $secret): string { $header = ['alg' => 'HS256', 'typ' => 'JWT']; $segments = [ b64url_encode(json_encode($header, JSON_THROW_ON_ERROR)), b64url_encode(json_encode($claims, JSON_THROW_ON_ERROR)), ]; $signingInput = implode('.', $segments); $signature = hash_hmac('sha256', $signingInput, $secret, true); $segments[] = b64url_encode($signature); return implode('.', $segments); } /** * 校验:显式指定算法白名单,绝不相信头部里的 alg */ function verify(string $token, string $secret, int $leeway = 60): array { $parts = explode('.', $token); if (count($parts) !== 3) { throw new InvalidArgumentException('token 结构不是三段'); } [$b64Header, $b64Payload, $b64Signature] = $parts; $header = json_decode(b64url_decode($b64Header), true, 512, JSON_THROW_ON_ERROR); if (($header['alg'] ?? null) !== 'HS256') { // 关键安全点:算法必须由服务端固定,不能由 token 头决定 throw new InvalidArgumentException('不支持的算法:' . var_export($header['alg'] ?? null, true)); } if (($header['typ'] ?? 'JWT') !== 'JWT') { throw new InvalidArgumentException('typ 不是 JWT'); } $expected = hash_hmac('sha256', "{$b64Header}.{$b64Payload}", $secret, true); $actual = b64url_decode($b64Signature); // 必须用 hash_equals 做时序安全比较,不能用 === if (!hash_equals($expected, $actual)) { throw new RuntimeException('签名不匹配'); } $claims = json_decode(b64url_decode($b64Payload), true, 512, JSON_THROW_ON_ERROR); $now = time(); if (isset($claims['nbf']) && $now + $leeway < (int) $claims['nbf']) { throw new RuntimeException('token 尚未生效(nbf)'); } if (isset($claims['exp']) && $now - $leeway >= (int) $claims['exp']) { throw new RuntimeException('token 已过期(exp)'); } return $claims; } // ---- 自检:先自签自验,确认环境没问题 ---- $secret = str_repeat('s', 32); // 生产环境请用 random_bytes(32) $token = sign(['sub' => '1001', 'iat' => time(), 'exp' => time() + 600], $secret); echo 'token = ', $token, PHP_EOL; try { $claims = verify($token, $secret); echo '自签自验通过,sub = ', $claims['sub'], PHP_EOL; } catch (Throwable $e) { echo '自签自验失败(说明环境或代码有问题):', $e->getMessage(), PHP_EOL; } // ---- 对比:用错误的密钥验证 ---- try { verify($token, str_repeat('x', 32)); } catch (Throwable $e) { echo '错误密钥:', $e->getMessage(), PHP_EOL; // 签名不匹配 } // ---- 对比:篡改 payload 后验证 ---- $tampered = explode('.', $token); $tampered[1] = b64url_encode(json_encode(['sub' => '9999', 'exp' => time() + 600], JSON_THROW_ON_ERROR)); try { verify(implode('.', $tampered), $secret); } catch (Throwable $e) { echo '篡改载荷:', $e->getMessage(), PHP_EOL; // 签名不匹配 } // ---- 对比:过期的 token ---- try { verify(sign(['exp' => time() - 3600], $secret), $secret); } catch (Throwable $e) { echo '过期 token:', $e->getMessage(), PHP_EOL; }排查时可以这样用:把线上收到的那个 token 丢进verify()。如果这里能过而框架里不能过,问题一定在"密钥来源"或"库的配置";如果这里也过不了,用b64url_decode()分别解出 header 和 payload 看一眼,能立刻区分是编码问题还是签名问题。
常见坑点
1. 用标准 base64 编解码
// ❌ JWT 用的是 base64url,标准 base64 遇到 -_ 会解不出来 $payload = json_decode(base64_decode($parts[1]), true);// ✅ 先还原字符表并补回填充 $payload = json_decode(b64url_decode($parts[1]), true);2. 用===比较签名
// ❌ 字符串比较是短路求值的,理论上可被时序攻击逐字节试探 if ($computed === $received) { /* 通过 */ }// ✅ 时序安全比较 if (hash_equals($computed, $received)) { /* 通过 */ }3. 信任 token 头部里的 alg
// ❌ 攻击者把 alg 改成 none,或用 RS256 公钥当 HS256 密钥,可以直接伪造 token $alg = json_decode(b64url_decode($parts[0]), true)['alg']; $ok = verifyWith($alg, $token);// ✅ 算法由服务端写死,头部里的 alg 值只用于比对 if (($header['alg'] ?? null) !== 'HS256') { throw new InvalidArgumentException('算法不允许'); }4. 密钥读取时被.env截断
# ❌ # 之后被当作注释丢弃,实际密钥变成 "abc" JWT_SECRET=abc#def123# ✅ 含特殊字符必须加引号 JWT_SECRET="abc#def123"5. 把签发端生成的密钥原样当成字节用
// ❌ 存的是 base64 文本,却当原始密钥参与 HMAC,长度和内容都不对 $secret = getenv('JWT_SECRET_B64');// ✅ 明确约定并在使用点解回来 $secret = base64_decode((string) getenv('JWT_SECRET_B64'), true);6. 时间校验零容差
// ❌ 服务器之间差 5 秒就会偶发 401,且"重启一下又好了" if ($claims['exp'] < time()) { throw new RuntimeException('expired'); }// ✅ 给一个小的容差窗口(例如 60 秒)覆盖时钟偏移 if ($claims['exp'] < time() - 60) { throw new RuntimeException('expired'); }7. 依赖$_SERVER['HTTP_AUTHORIZATION']却不做兜底
// ❌ 在 Apache + CGI 或某些 FPM 配置下这个键不存在,所有请求都被判为"未登录" $token = substr($_SERVER['HTTP_AUTHORIZATION'], 7);// ✅ 多来源兜底 + 类型判断(见上文 bearerToken()) $token = bearerToken() ?? throw new RuntimeException('未提供令牌');8. 改了 APP_KEY 却以为只影响加密数据
// ❌ 当 JWT 密钥取自配置里的 app.key 时,轮换 APP_KEY 会让所有已签发 token 失效 $secret = (string) config('app.key');// ✅ JWT 密钥独立配置,并在轮换期支持"新旧两个密钥同时可验" $secrets = [config('jwt.secret_new'), config('jwt.secret_old')];总结
| 症状 | 最可能的原因 | 定位手段 |
|---|---|---|
| 日志显示"未提供令牌" | Authorization头没透传到 PHP | 打印$_SERVER里所有HTTP_*与REDIRECT_*键 |
| 签名不匹配,密钥"看起来"一样 | 密钥被.env截断或未 base64 解码 | 两端各打印一次密钥长度与 SHA-256 前 8 位 |
| 解码报非法字符 | 用了标准 base64 而不是 base64url | 用b64url_decode()单独试解三段 |
| 升级库之后全部 401 | decode()的签名在大版本间变过 | 对照锁定的库版本查调用方式 |
| 偶发 401,重启后短暂正常 | 服务器时钟偏移,exp零容差 | 对比签发与验证机器的time(),加 leeway |
| 400/401 频繁且伴随 OPTIONS | 跨域预检未正确处理 | 查访问日志里 OPTIONS 请求的响应码 |
一句话结论:JWT 验证失败几乎从来不是"PHP 版本的问题",而是链路中某一处约定不一致——头像不被透传、密钥被截断、编码用错表、算法被信任地来自 token 自身、时钟没有容差。按本文的五段拆分逐段自查,通常十分钟内就能定位;其中"算法必须服务端固定"和"签名必须用hash_equals()比较"这两条不只是排查技巧,更是必须守住的安全底线。