Fork me on GitHub

JWT的Token生成

Java JWT 签名不一致问题:版本坑点与 PHP 跨语言兼容实战

JWT(JSON Web Token)广泛应用于用户身份验证,但在实际项目中跨语言解析 JWT 时,会踩到各种隐蔽的坑。Java 的 jwt 不同版本对密钥的处理差异,本文提供 Java/PHP 跨语言签名一致的完整解决方案。

🎯 问题背景

项目后端使用 Java 生成 JWT,前端或其他服务需要用 PHP 解码验证。但发现:

  • Java 生成的 JWT,在 PHP 解析时签名验证失败。
  • 即使 HeaderPayload 完全一致,PHP HMAC-SHA512 计算出来的签名却不同。

示例:

Java 生成的 JWT:

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJiZW1hc3RlciIsImxvZ2luX3VzZXJfa2V5IjoiM2I0YzYtNzY1NCJ9.QlJIV_dOD2_BueD-f9Gs7oZ1SdIbRyqlK8tlyH6mjWxlj3audD_Nzrw8cRy1AdrNYIX1TZgfxtySLJfmyKfVkw

PHP 重新生成后:

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJiZW1hc3RlciIsImxvZ2luX3VzZXJfa2V5IjoiM2I0YzYtNzY1NCJ9.NvllM3Yr1dh2LMU-FAPOnTzdFaLgohvLNf0-AIPWUUZhF_i9-hisd4E_vRskw3pPQyOCfVnDROKpNUTTuR8OxA

签名完全不同!

调试代码

打印php和java生成过程中的一些参数

java

private String createToken(Map<String, Object> claims)
    {
        System.out.println("Creating JWT token with claims: " + claims.toString());
        String headerJson = "{\"alg\":\"HS512\"}";
        String payloadJson = null; // 用Jackson或Gson序列化
        try {
            payloadJson = new ObjectMapper().writeValueAsString(claims);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        String header_b64 = TextCodec.BASE64URL.encode(headerJson);
        String payload_b64 = TextCodec.BASE64URL.encode(payloadJson);

        String signingInput = header_b64 + "." + payload_b64;
        System.out.println("Header Base64Url: " + header_b64);
        System.out.println("Payload Base64Url: " + payload_b64);
        System.out.println("Signing input: " + header_b64 + "." + payload_b64);
        System.out.println(Arrays.toString(secret.getBytes(StandardCharsets.UTF_8)));System.out.println("🔗 signing input: " + signingInput);
        System.out.println("🔐 key bytes: " + Arrays.toString(secret.getBytes(StandardCharsets.UTF_8)));

        Mac mac = null;
        try {
            mac = Mac.getInstance("HmacSHA512");
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
        try {
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
        } catch (InvalidKeyException e) {
            throw new RuntimeException(e);
        }
        byte[] sig = mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8));

        System.out.println("🧬 signature (raw hex): " + bytesToHex(sig));
        System.out.println("📤 signature (base64url): " + Base64.getUrlEncoder().withoutPadding().encodeToString(sig));

        String token = Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret).compact();
        System.out.println("Generated JWT token: " + token);
        return token;
    }

php


function base64url_encode(string $data): string {
	return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

// ==== 接收参数 ====
$username = $argv[1] ?? 'master';
$tokenId  = $argv[2] ?? '351e67e7-692c-4aa2-8f4b-ea83011a7ed4';
$secret   = 'abc123';
//$secret = JwtForJava::fakeJavaKeyDecode($secret); // 模拟 Java 密钥处理,这是后来添加的

$header = ['alg' => 'HS512'];
$claims = [
	'sub' => $username,
	'login_user_key' => $tokenId
];

// ==== 编码阶段 ====
$header_json  = json_encode($header, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$payload_json = json_encode($claims, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

$header_b64  = base64url_encode($header_json);
$payload_b64 = base64url_encode($payload_json);

$signing_input = $header_b64 . '.' . $payload_b64;

// ==== 签名阶段 ====
$signature_raw = hash_hmac('sha512', $signing_input, $secret, true);
$signature_b64 = base64url_encode($signature_raw);

$jwt = $header_b64 . '.' . $payload_b64 . '.' . $signature_b64;

// ==== 输出 ====
echo "🧾 原始 Header JSON:\n$header_json\r\n";
echo "📦 原始 Payload JSON:\n$payload_json\r\n";
echo "📤 Base64Url Header:\n$header_b64\r\n";
echo "📤 Base64Url Payload:\n$payload_b64\r\n";
echo "🔗 签名原始串 (signing input):\n$signing_input\r\n";
echo "🔒 HMAC-SHA512 签名 (raw bytes):\n" . bin2hex($signature_raw) . "\r\n";
echo "📤 Base64Url Signature:\n$signature_b64\r\n";
echo "✅ 最终 JWT:\n$jwt\r\n";
echo "✅ secret字节码:\n$jwt\r\n";
foreach (str_split($secret) as $c) {
	echo ord($c) . ' ';
}
echo "🔗 signing input:\n$signing_input\n";
echo "🔐 secret (UTF-8):\n" . implode(' ', array_map('ord', str_split($secret))) . "\n";

$signature_raw = hash_hmac('sha512', $signing_input, $secret, true);
echo "🧬 signature (raw hex):\n" . bin2hex($signature_raw) . "\n";

$signature_b64url = rtrim(strtr(base64_encode($signature_raw), '+/', '-_'), '=');
echo "📤 signature (base64url):\n$signature_b64url\n";

🔍 问题排查:Header、Payload、signing

逐步比对:

✅ Base64 编码:

Header: eyJhbGciOiJIUzUxMiJ9
Payload: eyJzdWIiOiJiZW1hc3RlciIsImxvZ2luX3VzZXJfa2V5IjoiM2I0YzYtNzY1NCJ9

一致。

✅ 原始签名串(signing input):

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJiZW1hc3RlciIsImxvZ2luX3VzZXJfa2V5IjoiM2I0YzYtNzY1NCJ9

一致。

❌ HMAC-SHA512 签名(原始字节、base64url):

不一致!

于是怀疑:Java 签名时用的密钥和PHP使用的密钥不一致!但是密钥明文命名一样的。

🧨 最终排查结果:老版本 JJWT 会偷偷 Base64 解码密钥!

在 JJWT(io.jsonwebtoken:jjwt)0.x 版本中,当这么写:

.signWith(SignatureAlgorithm.HS512, secretString)

它内部实际上会对这个字符串进行 Base64 解码

byte[] keyBytes = TextCodec.BASE64.decode(secretString);

也就是说:

  • 你以为传入的是 "abc123" 这个密钥;
  • 实际上,它会当作 Base64 处理成:base64_decode("abc123")

✅ 解决方案一:修复 Java 使用方式(推荐)

改为显式指定密钥字节,避免被 JJWT 隐式 decode:

import javax.crypto.spec.SecretKeySpec;

String secret = "abc123";
Key key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA512");

String token = Jwts.builder()
    .setClaims(claims)
    .signWith(key, SignatureAlgorithm.HS512) // ✅ 正确签名方式
    .compact();

JJWT 从 0.11.x 起正式推荐这种写法

✅ 解决方案二:不动 Java,PHP 模拟(兼容处理)

如果不能改 Java 代码(比如是第三方服务或已有代码),那只能让 PHP 来“配合”它的错误行为。

/**
 * 模拟 Java 对明文 secret 做 base64 decode 的逻辑
 */
function javaStyleSecret(string $secret): string {
    $padLen = 4 - (strlen($secret) % 4);
    if ($padLen < 4) {
        $secret .= str_repeat('=', $padLen);
    }
    return base64_decode($secret);
}

签名时这样写:

$signing_input = $header_base64 . '.' . $payload_base64;
$java_key = javaStyleSecret('abc123');
$signature = hash_hmac('sha512', $signing_input, $java_key, true);

🧪 验证代码片段(PHP):

$claims = [
    'sub' => 'master',
    'login_user_key' => '351e67e7-692c-4aa2-8f4b-ea83011a7ed4'
];
$secret = 'abc123';

// header + payload
$header_json = json_encode(['alg' => 'HS512'], JSON_UNESCAPED_SLASHES);
$payload_json = json_encode($claims, JSON_UNESCAPED_SLASHES);
$header_b64 = rtrim(strtr(base64_encode($header_json), '+/', '-_'), '=');
$payload_b64 = rtrim(strtr(base64_encode($payload_json), '+/', '-_'), '=');

// sign
$signing_input = $header_b64 . '.' . $payload_b64;
$key_bytes = javaStyleSecret($secret);
$signature_raw = hash_hmac('sha512', $signing_input, $key_bytes, true);
$signature_b64 = rtrim(strtr(base64_encode($signature_raw), '+/', '-_'), '=');

// JWT
$jwt = $signing_input . '.' . $signature_b64;
echo "JWT: $jwt
";

📌 小结

方式 原因 修复方法
Java signWith(SignatureAlgorithm, String) 会 base64 解码 secret ✅ 传入 SecretKeySpec
PHP 验证不一致 用了明文 secret 直接签名 ✅ PHP 也模拟 base64 解码 secret

🧠 经验总结

  • 不同语言对 JWT 的处理细节可能存在「隐式行为」差异,尤其是密钥处理。
  • 一旦涉及跨语言签名验证,务必检查header、payload、signing input、密钥是否一致处理
  • 推荐使用最新版 JWT 库,明确签名用法,避免神秘 bug 的来源

封装好的工具类

<?php

namespace app\util;

/**
 * 用于兼容 Java 旧版 jjwt 生成的 HS512 JWT 的工具类
 */
class JwtForJava
{
    /**
     * Base64 URL 安全编码
     */
    private static function base64url_encode(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }

    /**
     * Base64 URL 安全解码
     */
    private static function base64url_decode(string $data): string
    {
        $remainder = strlen($data) % 4;
        if ($remainder) {
            $data .= str_repeat('=', 4 - $remainder);
        }
        return base64_decode(strtr($data, '-_', '+/'));
    }

    /**
     * 模拟 Java 旧版本 jjwt 对 secret 的 base64 decode 行为
     */
    private static function javaStyleSecret(string $secret): string
    {
        $padLen = 4 - (strlen($secret) % 4);
        if ($padLen < 4) {
            $secret .= str_repeat('=', $padLen);
        }
        return base64_decode($secret) ?: $secret; // 如果不是合法 base64 就返回原始
    }

    /**
     * 生成 Java 旧版兼容 JWT(不含 typ,HS512)
     */
    public static function generate(array $claims, string $secret): string
    {
        $header_json = json_encode(['alg' => 'HS512'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $payload_json = json_encode($claims, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        $header_b64 = self::base64url_encode($header_json);
        $payload_b64 = self::base64url_encode($payload_json);
        $signing_input = $header_b64 . '.' . $payload_b64;

        $java_key = self::javaStyleSecret($secret);
        $signature = hash_hmac('sha512', $signing_input, $java_key, true);
        $signature_b64 = self::base64url_encode($signature);

        return $signing_input . '.' . $signature_b64;
    }

    /**
     * 验证 Java JWT 并解析 claims(返回 null 表示验证失败)
     */
    public static function parse(string $jwt, string $secret): ?array
    {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) {
            return null;
        }

        [$header_b64, $payload_b64, $signature_b64] = $parts;
        $signing_input = $header_b64 . '.' . $payload_b64;

        $java_key = self::javaStyleSecret($secret);
        $expected_signature = self::base64url_encode(
            hash_hmac('sha512', $signing_input, $java_key, true)
        );

        if (!hash_equals($expected_signature, $signature_b64)) {
            return null;
        }

        $payload_json = self::base64url_decode($payload_b64);
        $claims = json_decode($payload_json, true);
        return is_array($claims) ? $claims : null;
    }
}
posted @ 2025-07-08 21:26  秋夜雨巷  阅读(56)  评论(0)    收藏  举报