PHP8.1怎么使用JWT实现用户认证
发布时间:2026/10/1 18:41:26来源:尧图网络
前言用 JWTJSON Web Token做接口认证的项目出问题的姿势高度雷同本地一切正常上线后要么是「令牌永远不过期」要么是「用户改了密码旧令牌照样能用」最糟糕的一种是安全扫描直接报出「签名可绕过」。根因通常不在 JWT 本身而在于把它当成了一个「加密过的 cookie」。JWT 的前两段header 与 payload只是 Base64URL 编码任何人拿到令牌就能解开、看到里面的全部内容安全边界完全落在第三段的签名上。一旦签名校验写错——用比较、不限定算法、拿到false不看——JWT 就退化成一个可以任意伪造的明文 JSON。还需要先澄清一点JWT 不是 PHP 的语言特性PHP 8.1 并没有为它引入任何东西。它是一套令牌格式规范你既可以用hash_hmac()手写也可以用firebase/php-jwt之类的库。8.1 能帮上忙的只是语法层面枚举enumPHP 8.1、readonly提升属性PHP 8.1、never返回类型PHP 8.1这些能让签发与校验的代码少踩几个字面量拼错的坑。本文先讲清 JWT 的签名原理再给出一个不依赖任何第三方库、纯hash_hmac()实现的完整可运行示例含篡改检测与过期检测最后说明接入firebase/php-jwt与框架集成时的注意事项。一、JWT 的三段结构与签名原理一个 JWT 长这样用两个点分成三段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MDAwMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk段名称内容是否保密第 1 段header{alg:HS256,typ:JWT}的 Base64URL否可解码第 2 段payload声明claimssub、exp、iat、role等否可解码第 3 段signature对前两段的签名是但不可逆只能验不能解签名的计算方式是signature HMAC-SHA256( base64url(header) . . . base64url(payload) , secret )三个关键推论也是后面所有坑点的来源payload 不是加密的。把密码、手机号、身份证号放进 payload等于把它们贴在浏览器地址栏上。Base64 不是加密。签名只保证「没被改过」不保证「没被别人看过」。需要保密就上 JWEJSON Web Encryption或者干脆别把敏感数据放进去。验证必须用「先重算再定长比较」的方式而且重算时用的算法必须是服务端白名单里的算法绝不能听 header 里alg字段的。Base64URL 是 Base64 的变体换成-、/换成_并去掉末尾的填充以便安全地放进 URL、cookie 与 HTTP 头。很多「令牌一放进 URL 就失效」的问题就是忘了这一步转换。二、PHP 8.1 下用 hash_hmac 手写签发与校验不依赖库的版本只有一百来行而且把每个校验点都摆在明面上?php declare(strict_types1); // 最低 PHP 8.1用到了 enum、readonly 属性提升、never 返回类型、array_is_list final class JwtError extends RuntimeException {} /** 用户角色枚举让「字符串写错」在静态分析阶段就暴露 */ enum Role: string { case User user; case Admin admin; } /** * 解码后的令牌载荷。 * 用 readonly 提升属性PHP 8.1 特性注意不是 readonly 类——那是 PHP 8.2 才有的 * 保证拿到手后不会被意外改写。 */ final class Claims { public function __construct( public readonly int $sub, public readonly Role $role, public readonly int $exp, public readonly int $iat, ) {} } /** * 支持的算法白名单alg 名字 hash_hmac 的算法名 * 只允许 HS 系列杜绝 RS256/HS256 混淆与 algnone */ const JWT_ALGS [ HS256 sha256, HS384 sha384, HS512 sha512, ]; function base64UrlEncode(string $raw): string { return rtrim(strtr(base64_encode($raw), /, -_), ); } function base64UrlDecode(string $seg): string { $pad strlen($seg) % 4; if ($pad ! 0) { $seg . str_repeat(, 4 - $pad); // 解码前必须补回填充 } $raw base64_decode(strtr($seg, -_, /), true); if ($raw false) { throw new JwtError(base64url 段解码失败); } return $raw; } function jwtSign(string $signingInput, string $secret, string $alg): string { if (!isset(JWT_ALGS[$alg])) { throw new JwtError(不支持的算法$alg); } return hash_hmac(JWT_ALGS[$alg], $signingInput, $secret, true); } /** * 签发令牌。$ttl 是有效期秒数。 */ function jwtIssue(int $userId, Role $role, string $secret, int $ttl 900, string $alg HS256): string { $now time(); $header [alg $alg, typ JWT]; $payload [ sub $userId, role $role-value, iat $now, nbf $now, exp $now $ttl, ]; $h base64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)); $p base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR)); $signingInput $h . . . $p; return $signingInput . . . base64UrlEncode(jwtSign($signingInput, $secret, $alg)); } /** * 校验令牌。失败一律抛 JwtError绝不返回 false 让调用方自己猜。 */ function jwtVerify(string $jwt, string $secret, string $issuer my-app): Claims { $parts explode(., $jwt); if (count($parts) ! 3) { throw new JwtError(令牌格式不是三段); } [$h, $p, $s] $parts; $header json_decode(base64UrlDecode($h), true, 8, JSON_THROW_ON_ERROR); if (!is_array($header)) { throw new JwtError(header 不是对象); } // 关键算法只认服务端白名单绝不信 header 里写了什么 $alg $header[alg] ?? ; if (!is_string($alg) || !isset(JWT_ALGS[$alg])) { throw new JwtError(算法不在白名单内); } $expected base64UrlEncode(jwtSign($h . . . $p, $secret, $alg)); // 关键定长比较不能用 或 if (!hash_equals($expected, $s)) { throw new JwtError(签名不匹配); } $payload json_decode(base64UrlDecode($p), true, 16, JSON_THROW_ON_ERROR); if (!is_array($payload)) { throw new JwtError(payload 不是对象); } $now time(); foreach ([exp 已过期, nbf 尚未生效] as $key $msg) { if (!isset($payload[$key]) || !is_int($payload[$key])) { throw new JwtError(缺少 $key 声明); } if ($key exp $payload[$key] $now) { throw new JwtError($msg); } if ($key nbf $payload[$key] $now) { throw new JwtError($msg); } } if (($payload[iss] ?? $issuer) ! $issuer) { throw new JwtError(签发方不匹配); } $role Role::tryFrom((string) ($payload[role] ?? )); if ($role null) { throw new JwtError(未知角色); } return new Claims( sub: (int) $payload[sub], role: $role, exp: $payload[exp], iat: (int) $payload[iat], ); }这里有几个刻意的设计都是踩过坑之后才加的Role用枚举而不是字符串。校验$claims-role Role::Admin时拼错角色名会直接报错而不是静默地永远不成立。Claims用readonly类。令牌一旦验证通过中间件把它注入后续流程后任何一层都改不动它避免「越权」来自内部而不是外部。校验失败一律抛异常。返回false的接口最容易被调用方写成if (!$ok) { /* 忘了 return */ }那才是真正的漏洞。hash_equals()。它按位比较且耗时与内容无关。用比较签名会被时序攻击timing attack逐字节爆破。三、接入 firebase/php-jwt 与框架中间件生产项目一般直接用库省得自己维护加密细节。下面是 6.x 的 API 形态?php declare(strict_types1); require __DIR__ . /vendor/autoload.php; use Firebase\JWT\JWT; use Firebase\JWT\Key; use Firebase\JWT\ExpiredException; use Firebase\JWT\SignatureInvalidException; $secret $_ENV[JWT_SECRET] ?? ; // 密钥只从环境变量来绝不写进代码库 // 签发 $token JWT::encode( [sub 42, role admin, iat time(), exp time() 900], $secret, HS256 ); // 校验 try { $decoded JWT::decode($token, new Key($secret, HS256)); echo 通过sub . $decoded-sub . PHP_EOL; } catch (ExpiredException $e) { echo 令牌过期 . PHP_EOL; } catch (SignatureInvalidException $e) { echo 签名无效 . PHP_EOL; } catch (Throwable $e) { echo 其他失败 . $e-getMessage() . PHP_EOL; }注意6.x 里算法由Key对象带进来第三个参数$allowed_algs已废弃5.x 的decode()签名不同请以vendor/firebase/php-jwt/src/JWT.php里的实际签名为准不要照抄博客。框架里通常写成一个中间件。核心逻辑只有三句从Authorization: Bearer xxx里取出令牌、校验、把Claims挂到请求属性上。?php declare(strict_types1); /** * 认证失败直接终止请求。 * never 返回类型是 PHP 8.1 新增的它告诉静态分析器「这个函数不会正常返回」 * 因而后面不会出现「忘记 return 导致继续往下走」的漏网之鱼。 */ function abortUnauthorized(string $reason): never { http_response_code(401); header(Content-Type: application/json; charsetutf-8); echo json_encode([error $reason], JSON_UNESCAPED_UNICODE); exit; } function authenticate(string $authorizationHeader, string $secret): Claims { if (!str_starts_with($authorizationHeader, Bearer )) { abortUnauthorized(缺少 Bearer 令牌); } try { return jwtVerify(substr($authorizationHeader, 7), $secret); } catch (JwtError $e) { abortUnauthorized($e-getMessage()); } }其中的str_starts_with()是 PHP 8.0 引入的。校验项必须做不做的后果签名hash_equals()或库的 verify令牌可任意伪造算法服务端白名单algnone、HS/RS 混淆攻击exp与服务器时间比较令牌永久有效nbf/iat检查是否提前生效时钟漂移被利用iss/aud多系统共用密钥时必查别家系统的令牌能登你的系统角色枚举校验空角色被当成普通用户放行四、完整可运行示例把下面这段存成jwt_demo.php直接运行它会把「正常签发、被篡改、已过期」三种情况都跑一遍?php declare(strict_types1); // 需要 PHP 8.1。上面第二节的类与函数请一并放进这个文件。 $secret dev-only-secret-do-not-use-in-production; // ---------- 1. 正常签发与校验 ---------- $token jwtIssue(1001, Role::Admin, $secret, ttl: 900); echo 令牌$token\n; $claims jwtVerify($token, $secret); printf( 校验通过sub%d, role%s, 剩余 %d 秒\n, $claims-sub, $claims-role-value, $claims-exp - time() ); // ---------- 2. 篡改 payload把 role 改成 admin 或把 sub 改掉---------- $parts explode(., $token); $fake json_decode(base64UrlDecode($parts[1]), true, 16, JSON_THROW_ON_ERROR); $fake[sub] 1; $parts[1] base64UrlEncode(json_encode($fake, JSON_THROW_ON_ERROR)); $tampered implode(., $parts); try { jwtVerify($tampered, $secret); echo 严重问题篡改后的令牌居然通过了\n; } catch (JwtError $e) { echo 篡改被拦下{$e-getMessage()}\n; } // ---------- 3. 过期令牌 ---------- $expired jwtIssue(1001, Role::User, $secret, ttl: -10); try { jwtVerify($expired, $secret); echo 严重问题过期令牌居然通过了\n; } catch (JwtError $e) { echo 过期被拦下{$e-getMessage()}\n; } // ---------- 4. 换个密钥再验模拟密钥轮换后的旧令牌是否还能用---------- try { jwtVerify($token, another-secret); echo 严重问题错误密钥居然通过了\n; } catch (JwtError $e) { echo 错误密钥被拦下{$e-getMessage()}\n; }预期输出令牌eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... 校验通过sub1001, roleadmin, 剩余 900 秒 篡改被拦下签名不匹配 过期被拦下已过期 错误密钥被拦下签名不匹配常见坑点1. 用或比较签名❌if ($expected $signature) { ... }—— 字符串比较会在第一个不同字节处提前返回攻击者可以按位试探签名这是典型的时序攻击。 ✅if (hash_equals($expected, $signature)) { ... }定长比较耗时与内容无关。2. 直接相信 header 里的alg❌$alg $header[alg]; $sig hash_hmac($alg, ...);—— 攻击者把alg改成none或者把 RS256 改成 HS256 让你用公钥当 HMAC 密钥去验签。 ✅ 只认服务端硬编码的白名单数组isset(JWT_ALGS[$alg])不通过就直接拒。3. 用普通 base64 而不是 base64url❌base64_encode($payload)—— 输出里的/放进 URL 或 cookie 会被转义、截断令牌时灵时不灵。 ✅ 编码时strtr($s, /, -_)再去掉解码时先把-_换回来并补上填充。4. 把敏感信息放进 payload❌ 把密码哈希、手机号、订单金额写进 claims —— 只做了一次 base64任何抓包的人都能读到。 ✅ payload 只放标识和授权所需的最小信息用户 ID、角色、过期时间敏感数据按需查库。5. 只验签名不验exp❌JWT::decode()之后直接取出sub就用。库的校验再全也挡不住你自己签出exp缺失的历史令牌。 ✅ 显式检查exp、nbf、iss、aud把exp当作必填字段缺失即拒。6. HMAC 密钥是短口令❌$secret myapp2024;—— 这种密钥可以被离线爆破一旦爆破成功攻击者就能自己签发管理员令牌。 ✅ 用bin2hex(random_bytes(32))生成密钥存进环境变量或密钥管理服务并支持轮换同时接受新旧两把密钥一段时间。7. 以为「无状态」就等于「不能登出」❌ 用户点了退出前端删掉令牌就完事服务端手里的旧令牌还能一直用到过期。 ✅ access token 设短几分钟到十几分钟配一个可撤销的 refresh token需要即时失效就维护jti黑名单RedisTTL 设为令牌剩余寿命。8. 令牌放 localStorage 又忘了 cookie 的安全属性❌ 前端把令牌存 localStorage任何一次 XSS 都能直接偷走或者放 cookie 但不设HttpOnlySecureSameSite。 ✅ 放 cookie 时设HttpOnly、Secure、SameSiteLax配合 CSRF 防护坚持用 localStorage 就必须严格控制 XSS。总结环节做法要点令牌格式header.payload.signature 三段 Base64URLpayload 不保密签名HMAC-SHA256 hash_hmac()密钥随机且长比较hash_equals()防时序攻击算法服务端白名单防algnone与算法混淆声明exp/nbf/iss/aud全查缺一即拒有效期access 短 refresh 可撤销兼顾安全与体验传输cookie 加HttpOnly/Secure/SameSite防 XSS 与 CSRFJWT 本身并不复杂坑几乎全在「校验环节偷懒」上。把签名比较换成hash_equals()、把算法固化成白名单、把exp当成必填项、把密钥挪进环境变量——这四件事做完PHP 8.1 下的 JWT 认证就比大多数出过事的项目更稳。如果你的服务没有跨端、跨域、无状态横向扩展的需求传统的服务端 Session 往往更简单也更安全。
网站建设高端定制企业官网