目录

一、JWT简介

二、环境准备

三、用户注册与登录

3.1 数据库设计

3.2 注册接口

3.3 登录接口

四、验证JWT

五、 安全性考虑

六、先说个急事:你的firebase/php-jwt该升级了

七、refresh_token的正确姿势:不是简单“换票”

7.1 滚动刷新:用一次就作废

7.2 refresh_token存哪儿?怎么存?

7.3 黑名单膨胀问题:一个巧妙的解法

八、多端登录:怎么踢人不踢错

8.1 签发时带上jti和设备指纹

8.2 验证时检查jti有效性

8.3 踢人操作

九、跨域SSO场景的JWT

9.1 同根域:用Cookie省事

9.2 跨根域:用IdP做中继

十、容易被忽略的安全细节

10.1 密钥管理

10.2 算法混淆攻击

10.3 时钟偏移

10.4 签名失败不暴露细节

十一、前端配合:无感刷新怎么实现

十二、总结: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,存“该用户最后刷新时间” 。

流程是这样的:

  1. 签发refresh_token时,payload里带一个iat(签发时间)

  2. 用户请求刷新时,检查Redis里有没有该用户的“最后刷新时间”

  3. 如果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.comapp2.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流程了 :

  1. 用户访问app1.com,未登录,重定向到统一的认证中心(IdP)

  2. IdP验证用户身份,生成一个code(授权码)

  3. 重定向回app1.com,app1.com用code换token

  4. 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()主动捕获 。

十一、前端配合:无感刷新怎么实现

最后补充一下前端的配合逻辑。完整的无感刷新流程 :

  1. 请求拦截器:每次请求自动带上Authorization: Bearer <access_token>

  2. 响应拦截器:捕获401 Unauthorized

  3. 如果401是因为token过期,调用刷新接口(带refresh_token)

  4. 拿到新token后,重放失败的请求

  5. 如果刷新也失败(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依然在更新,新出的安全补丁也提醒我们:安全不是一劳永逸的,得持续关注 。

希望这篇文章,能帮你避开那些我在生产环境里踩过的坑。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐