
本文详解如何安全地将 rsa 公钥以 pem 字符串形式嵌入 jwt 的 claims 中,并在解析时动态还原为 *rsa.publickey,实现基于公钥分发的灵活验签逻辑。适用于多租户、密钥轮换或去中心化签名验证场景。
在标准 JWT 实践中,签名验证依赖服务端预置的公钥(如 jwt.Parse(..., func() { return publicKey })),但该方式缺乏灵活性:无法支持动态密钥切换、多租户独立密钥,或客户端自主选择验证方。一种常见误解是直接将 *big.Int 类型的 N/E 字段存入 claims——这不仅会因 JSON 序列化导致精度丢失(big.Int 被截断为 int64),更严重的是破坏了 JWT 的信任模型:若公钥本身可被任意篡改并嵌入 token,则签名完全失去防伪意义。
✅ 正确做法是:*将公钥序列化为标准 PEM 格式字符串(Base64 编码的 ASN.1 DER 结构),存入 claims;解析时再从该字符串反序列化为 `rsa.PublicKey**。PEM 是文本安全、标准兼容且无精度损失的表示方式,且jwt-go提供了开箱即用的jwt.ParseRSAPublicKeyFromPEM()` 辅助函数。
以下是完整可运行示例(基于 github.com/dgrijalva/jwt-go v3.x):
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
jwt "github.com/dgrijalva/jwt-go"
)
func main() {
// 1. 生成 RSA 密钥对(生产环境请使用 ≥2048 位)
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatal("生成私钥失败:", err)
}
// 2. 构建 token 并嵌入 PEM 格式公钥
token := jwt.New(jwt.SigningMethodRS256)
token.Claims = jwt.MapClaims{
"username": "victorsamuelmd",
"exp": time.Now().Add(time.Hour).Unix(),
// ✅ 关键:将公钥 Marshal 为 PKIX DER,再编码为 PEM 字符串
"public_key_pem": pemEncodePublicKey(&privateKey.PublicKey),
}
tokenString, err := token.SignedString(privateKey)
if err != nil {
log.Fatal("签名 token 失败:", err)
}
fmt.Println("已生成 token:", tokenString)
// 3. 解析 token 并动态提取公钥验签
parsedToken, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
// 验证签名算法是否符合预期
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("意外的签名方法: %v", t.Header["alg"])
}
// 从 claims 中读取 PEM 字符串并解析为 *rsa.PublicKey
claims, ok := t.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("claims 类型断言失败")
}
pemStr, ok := claims["public_key_pem"].(string)
if !ok {
return nil, fmt.Errorf("claims 中未找到 public_key_pem 字段或类型错误")
}
return jwt.ParseRSAPublicKeyFromPEM([]byte(pemStr))
})
if err != nil {
log.Fatal("解析/验签失败:", err)
}
if !parsedToken.Valid {
log.Fatal("token 验证失败:签名无效或已过期")
}
fmt.Println("✅ token 验证成功!Payload:", parsedToken.Claims)
}
// pemEncodePublicKey 将 *rsa.PublicKey 转为 PEM 格式字符串
func pemEncodePublicKey(pub *rsa.PublicKey) string {
bytes, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
panic("公钥序列化失败: " + err.Error())
}
pemBlock := &pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: bytes,
}
return string(pem.EncodeToMemory(pemBlock))
}⚠️ 重要注意事项:
- 安全性警示:此方案仅适用于可信上下文(如内部微服务通信、受控 API 网关)。若 token 可能被恶意第三方截获并重放,嵌入公钥会削弱“唯一可信签发者”模型——攻击者可伪造含自己公钥的 token。此时应坚持传统方式:服务端维护权威公钥池,通过 kid(Key ID)字段索引。
- 性能考量:每次解析都需 PEM 解析和 ASN.1 解码,比直接使用内存公钥稍慢。高并发场景建议缓存已解析的公钥(以 PEM 字符串为 key)。
- 密钥长度:示例使用 2048 位;生产环境推荐 3072 或 4096 位以满足长期安全要求。
- 库迁移提示:dgrijalva/jwt-go 已归档,新项目推荐迁移到 golang-jwt/jwt/v5,其 API 更清晰(如 jwt.WithValidMethods, jwt.WithValidator),且原生支持 func(token *Token) (any, error) 形式的 KeyFunc。
总结:通过 PEM 字符串嵌入公钥是一种实用的动态验签技术,它平衡了灵活性与标准兼容性。关键在于理解其适用边界——它不是替代传统公钥管理的银弹,而是特定架构下的有力补充。










