Golang 中 JWT 的签发和校验

JWT 是什么?

JSON Web Token(基于 JSON 的 Web 令牌)

即:JWT 是后端给前端发的一个加密字符串,用来免登录、身份认证、鉴权,代替传统的 Session/Cookie。

就是一长串用点 . 分隔的字符串,长这样:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

固定三段式,用 . 隔开。

RFC7519 标准:无规定最大长度,理论上可无限大。

JWT 只放必要、非敏感声明(一般建议<1KB),敏感数据可以使用 JWE 或放服务端。

结构 作用
Header 头部 存加密算法、令牌类型,比如 HS256、RS256。
Payload 载荷 存用户信息、业务数据(比如用户 ID、昵称、角色、过期时间)。
注意: 这里只是 Base64 编码,不是加密,别人能解码看到内容,不能放密码、敏感隐私数据。
Signature 签名 用密钥加密的签名。作用: 防止被篡改,验证是不是我们后端发的合法令牌。

在线生成&验证网站:jwt.io

JWE 是什么?

JWE = JSON Web Encryption(RFC 7516

它和 JWT 同属 JOSE(JSON Object Signing and Encryption)规范家族,专门解决一个问题:在令牌里安全传递敏感数据,不让中间人 / 前端直接看到内容。

结构:5 段(JWT 是 3 段)

JWT(JWS):Header.Payload.Signature(3 段)

JWE:ProtectedHeader.EncryptedKey.IV.Ciphertext.AuthenticationTag(5 段)

简单理解:

  • JWT:透明信封 + 防篡改封条(内容可见、不可改)
  • JWE:密封铁盒 + 锁(内容完全看不见,开锁才能读)
JOSE(总规范)
├─ JWS(签名,RFC7515)
├─ JWE(加密,RFC7516)
├─ JWT(令牌,RFC7519)← 基于 JWS/JWE
├─ JWK(密钥,RFC7517)
└─ JWA(算法,RFC7518)

鉴权 & 安全

常用鉴权流程

  1. 账号密码登录,后端校验通过。
  2. 后端生成一个 JWT 返回给前端。
  3. 前端把 JWT 存起来(LocalStorage / Cookie)。
  4. 之后每次请求接口,请求头带上 Authorization: Bearer JWT字符串。
  5. 后端解析、校验签名、看是否过期、拿到用户信息,放行 / 拒绝。

安全注意事项

常见安全风险(更多内容可参考:RFC8725: Threats and Vulnerabilities):

场景 说明
签名校验问题 部分jwt实现库在攻击者将 alg 改为 none 的时候,不校验签名。
部分库在攻击者将 RS256 算法替换成 HS256 时,使用公钥作为对称密钥校验签名。
弱对称密钥 弱对称密钥可被离线爆破
未验证JWE中的JWS签名 部分库从JWE中解密出JWS后,没有对其中的签名进行校验

还有一种安全风险是,客户端使用jwt作为LICENSE,但使用了对称加密算法。这种情况下客户端中必定会存储一份加密密钥,存在被逆向提取风险(比如 Crawlab-pro)。

Golang 示例代码

  1. 使用 jwt.NewWithClaims 方法创建 jwt 对象。
  2. 使用 NewWithClaims 方法传入密钥进行签名。
  3. 使用 ParseWithClaims 方法解析jwt字符串。
  4. 使用 token.valid 验证jwt对象是否有效。
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package main

import (
	"errors"
	"fmt"
	"log"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// JWTClaims JWT声明
type JWTClaims struct {
	UserID       uint64 `json:"user_id"`
	Username     string `json:"username"`
	IsSuperAdmin bool   `json:"is_super_admin"`
	jwt.RegisteredClaims
}

var (
	jwt_expire  int    = 24
	jwt_subject string = "test-subject"
	jwt_issuer  string = "test-issuer"
	jwt_secret  string = "test-secret-just-a-long-string"
)

// GenerateToken 生成JWT令牌
func GenerateToken(userID uint64, username string, isSuperAdmin bool) (string, error) {

	// 设置过期时间
	expireTime := time.Now().Add(time.Hour * time.Duration(24))

	claims := JWTClaims{
		UserID:       userID,
		Username:     username,
		IsSuperAdmin: isSuperAdmin,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(expireTime),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
			Issuer:    jwt_issuer,
			Subject:   jwt_subject,
		},
	}

	// 创建令牌
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	// 签名
	return token.SignedString([]byte(jwt_secret))
}

// ParseToken 解析JWT令牌
func ParseToken(tokenString string) (*JWTClaims, error) {

	token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
		// 验证签名方法
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, errors.New("unexpected signing method")
		}
		return []byte(jwt_secret), nil
	})

	if err != nil {
		return nil, err
	}

	if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
		return claims, nil
	}

	return nil, errors.New("invalid token")
}

// RefreshToken 刷新令牌
func RefreshToken(tokenString string) (string, error) {
	claims, err := ParseToken(tokenString)
	if err != nil {
		return "", err
	}

	// 如果令牌即将过期(剩余时间小于一半),则刷新
	expireTime := claims.ExpiresAt.Time
	now := time.Now()

	if expireTime.Sub(now) < time.Hour*time.Duration(jwt_expire)/2 {
		return GenerateToken(claims.UserID, claims.Username, claims.IsSuperAdmin)
	}

	// 否则返回原令牌
	fmt.Println("时间未过半,暂不刷新")
	return tokenString, nil
}

func main() {
	jwt_str, err := GenerateToken(1234, "test-user", true)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(jwt_str)

	jwt_obj, err := ParseToken(jwt_str)
	if err != nil {
		log.Fatal(err)
	} else {
		log.Printf("%s jwt 有效\n", jwt_obj.Username)
	}

	new_jwt_str, err := RefreshToken(jwt_str)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("jwt刷新结果:%s", new_jwt_str)
}

输出:

1
2
3
4
5
6
$ go run .
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjM0LCJ1c2VybmFtZSI6InRlc3QtdXNlciIsImlzX3N1cGVyX2FkbWluIjp0cnVlLCJpc3MiOiJ0ZXN0LWlzc3VlciIsInN1YiI6InRlc3Qtc3ViamVjdCIsImV4cCI6MTc3ODAwNzMwMCwibmJmIjoxNzc3OTIwOTAwLCJpYXQiOjE3Nzc5MjA5MDB9.DjW_gEgPSAiDfSBO_C0FO9wa5nW-xMXUcgYn2sG7WSc
2026/05/05 02:55:00 test-user jwt 有效
时间未过半,暂不刷新
jwt刷新结果:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjM0LCJ1c2VybmFtZSI6InRlc3QtdXNlciIsImlzX3N1cGVyX2FkbWluIjp0cnVlLCJpc3MiOiJ0ZXN0LWlzc3VlciIsInN1YiI6InRlc3Qtc3ViamVjdCIsImV4cCI6MTc3ODAwNzMwMCwibmJmIjoxNzc3OTIwOTAwLCJpYXQiOjE3Nzc5MjA5MDB9.DjW_gEgPSAiDfSBO_C0FO9wa5nW-xMXUcgYn2sG7WSc
$ 

一些平台常见使用 双jwt 方案,即 AccessTokenRefreshToken

  1. AccessToken 有业务操作权限,短有效期,在服务器没有存储状态,可以快速鉴权(只需要验证签名)。
  2. RefreshToken 没有业务操作权限,长有效期,只能用于续签 AccessToken,在服务器存储有状态,可以手动吊销。

RefreshToken可以不使用jwt形式,而使用比如uuid等字符串,存储到redis中,设置有效期,到期自动删除,或手动删除(吊销)。

前端使用AccessToken调接口,发现返回401,就自动使用RefreshToken去刷新,刷新失败就跳转登录。

如果采用单jwt在服务器存储状态,虽然也能实现手动吊销,但是当并发数量大时,数据库压力太大,会成为瓶颈甚至故障点。

本质上两个jwt没有区别,只是在签发时设置的有效期不同,同时在payload中标识了当前jwt可用于业务还是续签。比如:

  • AccessToken 的 payload(业务令牌)
1
2
3
4
5
6
{
  "sub": 1001,
  "type": "access",   // 标记是业务令牌
  "exp": 15分钟后,
  "roles": ["admin"], // 有权限
}
  • RefreshToken 的 payload(仅续签令牌)
1
2
3
4
5
6
{
  "sub": 1001,
  "type": "refresh",  // 标记是续签令牌
  "exp": 7天后,
  // 无权限字段!
}
updatedupdated2026-05-052026-05-05