在做 PHP 8.1 下的 JWT 签名验证时,有几个坑是新手必踩的。最典型的,就是私钥加载方式不对——直接把 -----BEGIN RSA PRIVATE KEY----- 这类 PEM 字符串扔给 openssl_sign(),它连个错误都不报,静默返回 false,让人一头雾水。此外,私钥权限、密码传递、算法匹配、Base64Url 解码习惯、时区一致性和时间字段的手动校验,每一环都有容易忽视的细节。下面逐个拆解,确保你的 token 能正常生成和验证。

JWT生成必须用openssl_pkey_get_private()加载私钥,不能直接传PEM字符串
PHP 8.1+ 的 OpenSSL 扩展默认禁用了弱算法,但更常见的失败点其实是私钥加载方式。直接传 PEM 字符串给 openssl_sign(),它会默默返回 false,而且不抛任何异常。正确做法是先调用 openssl_pkey_get_private() 解析私钥的内容,拿到资源句柄,再传给签名函数。
- 私钥文件权限必须设置为
600,否则file_get_contents()可能读取失败——尤其是在 CLI 模式下,权限不对直接没内容。 - 如果私钥有密码保护,
openssl_pkey_get_private($pem, $password)的第二个参数不能传空字符串,必须是真实的密码,否则也会返回false。 - 千万别用
openssl_pkey_new()在运行时生成 RSA 密钥对——不同 PHP 版本行为不一致,导出格式容易出错,推荐直接在系统命令行用openssl genrsa生成,稳定可靠。
验证时 alg 字段必须与 openssl_verify() 或 hash_hmac() 严格匹配
JWT 头部的 alg 可不是装饰字段——它直接决定底层调用哪个验证逻辑。写错了,验证时也不报错,只返回 false,排查起来极费劲。
- Header 里写的是
"alg": "RS256",就必须用openssl_verify()+ 公钥资源;绝对不能拿hash_hmac()去验,算法不匹配必然失败。 - Header 里是
"alg": "HS256",就得用hash_hmac('sha256', ...),密钥必须是原始字节(比如random_bytes(32)),而不是 base64 编码后的字符串——很多人栽在这里。 - 如果用
firebase/php-jwt这个库,JWT::decode($token, $key, ['RS256'])的第三个参数不能省略,漏掉或值不匹配会抛出DomainException: Algorithm not allowed。 - 公钥必须是标准 PEM 格式,以
-----BEGIN PUBLIC KEY-----开头,中间不能有多余空格或换行。用openssl_pkey_get_public()加载时若返回false,记得提前检查。
Base64Url 解码不能用 base64_decode() 直接替代
JWT 的三段 payload 都采用 Base64Url 编码,和标准 Base64 有两个关键区别:+ 变成 -,/ 变成 _,而且末尾不补 =。直接用 base64_decode() 一把梭,大概率解码失败或出现乱码,尤其在 Signature 段。
- 必须自己处理转换:先
str_replace(['-', '_'], ['+', '/'], $input),再base64_decode(),同时手动补上可能缺失的=符号。 - 第三方库(如
firebase/php-jwt)内部已经封装好了,但如果你自己手写 JWT 实现,这个细节特别容易漏。 - Header 和 Payload 解码后记得用
json_decode(..., true)转成数组,否则直接用对象方式访问(如$header->alg)可能会报错。
time() 和 exp/nbf 校验必须设为 UTC 且手动比对
JWT::decode() 默认只做签名和算法校验,不会自动检查 exp、nbf、iat 这些时间字段。PHP 8.1+ 环境下,本地开发时区与服务器不一致是导致过期判断失败的头号原因。
- 务必在脚本开头调用
date_default_timezone_set('UTC'),否则time()返回的是本地时间戳,和 token 里的 UTC 时间戳对比时必然出错。 - 验证逻辑必须显式写出来:
if (isset($decoded->exp) && $decoded->exp < time()) { throw ... },别指望框架帮你做。 - 不要依赖
JWT::decode()的['verify_exp' => true]这种配置——v6+ 版本已经移除了,必须手动判断。 exp和nbf都是 Unix 时间戳,单位秒,且必须是整数。如果前端传来浮点数(比如 JS 的Date.now() / 1000),记得用floor()或(int)转一下再比较。
RSA 密钥对的格式、时区设置、Base64Url 边界处理、以及时间字段的手动校验——这四点在 PHP 8.1 + OpenSSL 环境下最容易被跳过,但任一缺失都会导致 token 表面正常、实际失效或被绕过。希望上面的梳理能帮你避开这些坑,让 JWT 在项目中稳稳跑起来。