使用PHP和JWT实现安全的API身份验证
PHP中使用JWT实现API身份验证的完整流程。首先概述JWT的组成结构(Header、Payload、Signature),然后详细讲解环境准备、用户注册登录接口的实现方法,包括数据库设计、密码哈希处理和JWT生成。接着说明如何验证JWT令牌,并强调安全性考虑因素:HTTPS传输、密钥管理、令牌过期设置等。最后总结JWT无状态认证的优势,适合分布式系统和前后端分离架构,同时提醒开发者还需关注其他
目录
六、先说个急事:你的firebase/php-jwt该升级了
一、JWT简介
JWT由三部分组成:

Header-包含令牌的类型和使用的哈希算法(如HMAC SHA256或RSA)。
Payload-包含声明(claims)。声明是关于实体(通常是用户)和其他数据的,有三种类型:注册声明、公共声明和私有声明。
Signature-对前两部分的签名,防止数据篡改。
例如,一个JWT看起来像这样:`xxxxx.yyyyy.zzzzz`(三个Base64-URL字符串用点分隔)。
二、环境准备
在开始之前,确保你已经安装了PHP(建议7.4以上版本)和Composer。我们这里使用`firebase/php-jwt`库来处理JWT的生成和验证。
composer require firebase/php-jwt
三、用户注册与登录
3.1 数据库设计
为了简单起见,我们使用MySQL数据库。创建一个名为users的表:
CREATE TABLE `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL UNIQUE,
`password` VARCHAR(255) NOT NULL
);
注意: 密码必须是经过哈希处理,用到的是PHP的password_hash()函数。
3.2 注册接口
在注册接口中,我们接收用户名和密码,将密码哈希后存储到数据库。
<?php
require 'vendor/autoload.php';
use Firebase\JWT\JWT;
// 连接数据库
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', 'password');
function register($username, $password) {
global $pdo;
// 检查用户名是否已存在
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetch()) {
return ['error' => 'User already exists'];
}
// 哈希密码
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password) VALUES (?, ?)");
$stmt->execute([$username, $hash]);
return ['message' => 'User registered successfully'];
}
3.3 登录接口
登录时,验证用户名和密码,如果正确,则生成JWT令牌返回给客户端。
function login($username, $password) {
global $pdo;
$stmt = $pdo->prepare("SELECT id, password FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password'])) {
return ['error' => 'Invalid credentials'];
}
// 生成JWT
$secret_key = "your_secret_key"; // 应该是一个复杂的随机字符串,存储在环境变量中
$payload = [
'iss' => 'localhost', // 签发者
'aud' => 'localhost', // 接收方
'iat' => time(), // 签发时间
'exp' => time() + 3600, // 过期时间(1小时)
'data' => [
'user_id' => $user['id'],
'username' => $username
]
];
$jwt = JWT::encode($payload, $secret_key, 'HS256');
return ['token' => $jwt];
}
四、验证JWT
在需要身份验证的API(例如用户个人信息接口)中,我们需要验证客户端传来的JWT。
function getProfile() {
// 获取Authorization头
$headers = getallheaders();
if (!isset($headers['Authorization'])) {
http_response_code(401);
return ['error' => 'Token not provided'];
}
$token = str_replace('Bearer ', '', $headers['Authorization']);
$secret_key = "your_secret_key";
try {
$decoded = JWT::decode($token, new Firebase\JWT\Key($secret_key, 'HS256'));
// 如果验证成功,$decoded将包含我们之前设置的payload
$user_id = $decoded->data->user_id;
// 现在可以根据user_id从数据库获取用户信息并返回
// ... 数据库查询代码 ...
return ['user' => $userInfo];
} catch (Exception $e) {
http_response_code(401);
return ['error' => 'Invalid token'];
}
}
五、 安全性考虑
- 使用HTTPS:在传输过程中保护JWT,防止被截获。
- 密钥安全:密钥(
secret_key)必须足够复杂,且不要硬编码在代码中,应该使用环境变量。 - 令牌过期:设置合理的过期时间(如1小时),并考虑使用刷新令牌机制来延长会话。
- 存储方式:客户端通常将JWT存储在localStorage或sessionStorage中,但要注意XSS攻击。也可以使用HttpOnly的Cookie来存储,避免XSS,但要注意CSRF防护。
上一篇文章我们聊了JWT的基础用法和核心流程。文章发出来后,不少朋友在后台问我一些更落地的问题:
-
“token丢了怎么办?别人拿着我的token是不是能一直用?”
-
“用户登出了,token还没过期,怎么让它失效?”
-
“同一个用户在不同设备登录,怎么做到互不干扰?”
-
“refresh_token到底存哪儿?要不要入库?”
这些问题,说实话,比“怎么生成token”难多了。它们是生产环境里真正要面对的坑。今天这篇补充文章,就专门聊聊这些“进阶但必须解决”的事。
六、先说个急事:你的firebase/php-jwt该升级了
如果你读到这里,第一件事是去项目目录里跑个命令:
composer audit
如果看到类似这样的输出:
Package firebase/php-jwt has a security vulnerability:
CVE-2025-45769 - php-jwt contains weak encryption
Affected versions: <7.0.0
说明你中招了 。
这是2025年7月曝出来的一个高危漏洞,影响6.x及以下版本。具体细节我就不展开了,但解决方案很简单:
composer require firebase/php-jwt:^7.0
为什么强调这个? 因为很多老项目用的还是5.x甚至4.x版本,composer.json里写死了版本号,一直没升级。2026年的今天,如果你的JWT库还停留在6.x以下,等于给攻击者留了后门 。
七、refresh_token的正确姿势:不是简单“换票”
很多教程教的是:access_token过期了,用refresh_token换一个新的。然后呢?旧的refresh_token还能接着用。这是最典型的错误。
7.1 滚动刷新:用一次就作废
正确的做法叫滚动刷新:每次用refresh_token换新token时,旧的refresh_token必须立即失效,同时生成一个新的refresh_token返回给前端 。
// refresh_token换取新token的接口
public function refresh(Request $request)
{
$oldRefreshToken = $request->input('refresh_token');
// 1. 校验旧refresh_token是否有效、是否在黑名单
if (!$this->validateRefreshToken($oldRefreshToken)) {
return response()->json(['error' => '无效的refresh_token'], 401);
}
// 2. 生成新的access_token和refresh_token
$userId = $this->getUserIdFromRefreshToken($oldRefreshToken);
$newAccessToken = $this->createAccessToken($userId);
$newRefreshToken = $this->createRefreshToken($userId); // 新refresh_token
// 3. 事务:旧refresh_token加入黑名单,新refresh_token入库
DB::transaction(function () use ($oldRefreshToken, $newRefreshToken) {
$this->revokeRefreshToken($oldRefreshToken); // 旧token作废
$this->storeRefreshToken($newRefreshToken); // 新token存库
});
return response()->json([
'access_token' => $newAccessToken,
'refresh_token' => $newRefreshToken,
'expires_in' => 900 // 15分钟
]);
}
为什么必须这样?想象一个场景:用户的refresh_token被盗了。攻击者用它换了一次新token,如果旧的还能用,那攻击者和真正的用户都能无限续期——谁先动手谁赢,后动手的反而被踢 。
7.2 refresh_token存哪儿?怎么存?
先说结论:
-
access_token不入库(本来就是无状态的)
-
refresh_token必须落库,而且只能存哈希
表结构可以参考这样 :
CREATE TABLE refresh_tokens (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
token_hash VARCHAR(255) NOT NULL, -- 存bcrypt哈希,不存明文
fingerprint VARCHAR(255), -- 设备指纹
ip_address VARCHAR(45),
user_agent TEXT,
expires_at DATETIME NOT NULL,
revoked_at DATETIME NULL,
created_at DATETIME NOT NULL,
INDEX idx_user_id (user_id),
INDEX idx_token_hash (token_hash)
);
为什么不能存明文? 因为refresh_token是长期凭证(可能7天、30天),一旦数据库泄露,所有用户的refresh_token明文就全丢了。哈希之后,即使泄露也无法直接使用 。
7.3 黑名单膨胀问题:一个巧妙的解法
如果每个refresh_token都存一条记录,活跃用户多的系统,这张表会膨胀得很快。而且refresh_token天然是“一次性的”(用完就作废),废弃记录会越来越多。
有个解法挺巧妙:用Redis,以用户ID为key,存“该用户最后刷新时间” 。
流程是这样的:
-
签发refresh_token时,payload里带一个
iat(签发时间) -
用户请求刷新时,检查Redis里有没有该用户的“最后刷新时间”
-
如果
iat小于“最后刷新时间”,说明这个token是旧的,拒绝
// 刷新时
Redis::setex("user:{$userId}:last_refresh", $refreshTokenExpire, time());
// 验证时
$lastRefresh = Redis::get("user:{$userId}:last_refresh");
if ($lastRefresh && $payload->iat < $lastRefresh) {
throw new Exception('refresh_token已过期'); // 实际上是旧的
}
这个方案的好处是:每个用户只存一条记录,而不是每个token一条,解决了黑名单膨胀问题 。
八、多端登录:怎么踢人不踢错
现在用户都有手机、平板、电脑好几台设备。产品经理的需求往往是:
-
支持多端同时登录(互不干扰)
-
支持从某端手动登出(不影响其他端)
-
支持管理员后台踢掉某端
这就要靠 jti + 设备指纹 了 。
8.1 签发时带上jti和设备指纹
$payload = [
'user_id' => $user->id,
'jti' => bin2hex(random_bytes(16)), // 唯一ID
'fingerprint' => $this->generateFingerprint($request), // 设备指纹
'exp' => time() + 900, // 15分钟
'iat' => time()
];
$token = JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');
// 同时把jti存入数据库,关联user_id和fingerprint
DB::table('active_tokens')->insert([
'jti' => $payload['jti'],
'user_id' => $user->id,
'fingerprint' => $payload['fingerprint'],
'expires_at' => date('Y-m-d H:i:s', $payload['exp'])
]);
8.2 验证时检查jti有效性
中间件里,验证完签名后,还要查一下这个jti是否还在“有效列表”里:
$exists = DB::table('active_tokens')
->where('jti', $decoded->jti)
->where('expires_at', '>', now())
->exists();
if (!$exists) {
throw new Exception('token已失效'); // 被踢了
}
8.3 踢人操作
要踢掉某个设备,直接从active_tokens表删掉对应的jti记录就行。下次那个设备发请求时,验证就会失败 。
// 管理员后台踢掉指定设备
DB::table('active_tokens')
->where('user_id', $userId)
->where('jti', $jti)
->delete();
九、跨域SSO场景的JWT
如果你的系统涉及多个子域(比如app1.example.com、app2.example.com)共用同一套登录,JWT也能派上用场 。
9.1 同根域:用Cookie省事
如果多个子域同属一个根域(.example.com),可以用HttpOnly + Secure + SameSite=Lax的Cookie存token,把Cookie的Domain设为.example.com 。
setcookie(
'token',
$jwt,
[
'expires' => time() + 900,
'path' => '/',
'domain' => '.example.com', // 关键!
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]
);
这样所有子域都能自动带上这个Cookie,后端统一解析即可。
9.2 跨根域:用IdP做中继
如果域完全不同(比如app1.com和app2.net),就得用标准的SSO流程了 :
-
用户访问app1.com,未登录,重定向到统一的认证中心(IdP)
-
IdP验证用户身份,生成一个code(授权码)
-
重定向回app1.com,app1.com用code换token
-
app2.net同理,重定向到IdP,IdP检测到用户已登录(有会话),直接发code
这里面JWT的作用是:IdP签发的token,各个SP(业务系统)用共享密钥或公钥验证,不需要每次回查IdP 。
十、容易被忽略的安全细节
10.1 密钥管理
-
别把密钥写在代码里。用环境变量,或者专门的密钥管理服务(Vault、KMS)。
-
定期轮换密钥。但要注意:轮换时,之前签发的token怎么办?可以用多个密钥同时生效,签名用新密钥,验证时新旧都试一遍。
10.2 算法混淆攻击
如果你用RSA公钥验签,但攻击者把token头部改成HS256,然后用公钥作为密钥来签名——如果你没指定算法白名单,PHP可能会用公钥当HS256的密钥去验,结果验证通过 。
解法:验证时明确指定允许的算法 。
JWT::decode($token, new Key($publicKey, 'RS256')); // 只接受RS256
10.3 时钟偏移
如果IdP和各业务系统的服务器时间不一致,可能exp验证失败。建议加个“宽容窗口” :
JWT::$leeway = 60; // 允许60秒的时间差
10.4 签名失败不暴露细节
try {
$decoded = JWT::decode($token, $key, ['HS256']);
} catch (Exception $e) {
// 记录日志,但不要返回给客户端具体错误信息
error_log('JWT验证失败:' . $e->getMessage());
http_response_code(401);
echo json_encode(['error' => '未授权']);
exit;
}
有时候openssl_pkey_get_public()加载公钥失败,不会报错,要用openssl_error_string()主动捕获 。
十一、前端配合:无感刷新怎么实现
最后补充一下前端的配合逻辑。完整的无感刷新流程 :
-
请求拦截器:每次请求自动带上Authorization: Bearer <access_token>
-
响应拦截器:捕获401 Unauthorized
-
如果401是因为token过期,调用刷新接口(带refresh_token)
-
拿到新token后,重放失败的请求
-
如果刷新也失败(refresh_token也过期),跳转登录页
伪代码(axios):
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 如果是401且还没重试过
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const res = await axios.post('/auth/refresh');
const newToken = res.data.access_token;
// 更新本地token
localStorage.setItem('access_token', newToken);
// 重试原请求
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return axios(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
十二、总结:JWT不是银弹,但用对了很好
写到最后,我想说一句实话:JWT最大的优点是无状态,最大的缺点也是无状态。
无状态意味着:签发之后,服务器就没法主动控制它了。要“踢人”、要“登出”、要“禁止某设备”,都得靠额外的状态存储(黑名单、活跃token表)。所以别被“无状态”忽悠了——大多数业务场景,你最终还是得有点状态 。
但即便如此,JWT依然是API认证的最佳选择之一。它让服务间信任传递变得简单,让微服务认证变得统一,让跨端登录变得可控。
2026年,JWT生态依然活跃,firebase/php-jwt依然在更新,新出的安全补丁也提醒我们:安全不是一劳永逸的,得持续关注 。
希望这篇文章,能帮你避开那些我在生产环境里踩过的坑。
更多推荐
所有评论(0)