golang-jwt/jwt は Go で JWT を扱うときのデファクトです。ただし v5 で StandardClaims が消え、Claims インターフェイスが getter 形式に変わりました。v4 のサンプルを貼ると undefined: jwt.StandardClaims でビルドが落ちます。
ここでは v5.3.1(2026-01-28 リリース)を前提に、トークン発行から検証ミドルウェア、v4 からの差分、alg=none 対策までを実装で踏みます。公式 MIGRATION_GUIDE.md の “Parsing and Validation Options” および “Changes to the Claims interface” 節を主な参照元にしています。
golang-jwt v5でアクセストークンを発行する
導入とimport pathの注意
v5 の import path は github.com/golang-jwt/jwt/v5 です。v4 の github.com/golang-jwt/jwt/v4 と並行で入れることもできますが、混在は事故のもと。go.mod から v4 は削っておきます。
go get github.com/golang-jwt/jwt/v5@v5.3.1
go: added github.com/golang-jwt/jwt/v5 v5.3.1
NewWithClaimsとSignedStringで署名する
HS256 で15分有効のアクセストークンを発行する最短コードがこれです。RegisteredClaims は v5 で追加された標準クレーム構造体で、StandardClaims の後継にあたります。
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type AppClaims struct {
UserID string `json:"uid"`
Role string `json:"role"`
jwt.RegisteredClaims
}
var secret = []byte("replace-with-32byte-random-secret")
func Issue(userID, role string) (string, error) {
now := time.Now()
claims := AppClaims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "mohablog",
Subject: userID,
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func main() {
s, _ := Issue("u-001", "admin")
fmt.Println(s)
}
$ go run .
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJ1LTAwMSIsInJvbGUiOiJhZG1pbiIsImlzcyI6Im1vaGFibG9nIiwic3ViIjoidS0wMDEiLCJleHAiOjE3NjI1MDA0MDAsIm5iZiI6MTc2MjQ5OTUwMCwiaWF0IjoxNzYyNDk5NTAwfQ.4o4G6xrW-ZPq3sGy8gWZbY3xCnQk7Xf3lDoz2yYqgcc
署名アルゴリズムは jwt.SigningMethodHS256。文字列の "HS256" ではなく型を渡す点に注意してください。SignedString の引数は any で受けますが、HMAC 系は []byte、RSA は *rsa.PrivateKey、ECDSA は *ecdsa.PrivateKey を渡します。
ParseWithClaimsで検証ミドルウェアを書く
Authorizationヘッダの抽出と検証
net/http の標準的なミドルウェアとして書くと、ハンドラ側は r.Context() から claims を取り出すだけになります。
package auth
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type ctxKey struct{}
func Middleware(secret []byte) func(http.Handler) http.Handler {
parser := jwt.NewParser(
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithLeeway(30 * time.Second),
jwt.WithExpirationRequired(),
)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
var claims AppClaims
_, err := parser.ParseWithClaims(raw, &claims, func(t *jwt.Token) (any, error) {
return secret, nil
})
if err != nil {
switch {
case errors.Is(err, jwt.ErrTokenExpired):
http.Error(w, "token expired", http.StatusUnauthorized)
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
http.Error(w, "bad signature", http.StatusUnauthorized)
default:
http.Error(w, "invalid token", http.StatusUnauthorized)
}
return
}
ctx := context.WithValue(r.Context(), ctxKey{}, &claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
動作確認は curl で済みます。期限切れトークンを投げたときのレスポンスがこれです。
$ curl -i -H "Authorization: Bearer ${EXPIRED_TOKEN}" http://localhost:8080/me
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
token expired
errors.Isでエラー種別を分岐する
v5 はエラーを jwt.ErrTokenExpired、jwt.ErrTokenNotValidYet、jwt.ErrTokenSignatureInvalid、jwt.ErrTokenMalformed など細粒度の sentinel error に分けています。文字列比較ではなく errors.Is で判定。ログに種別を残すと、攻撃と単なる期限切れの切り分けが楽になります。
v4からv5への移行で詰まりやすい点
差分は MIGRATION_GUIDE の “Changes to the Claims interface” 節に網羅されています。実装で踏むものに絞って整理しました。
| 項目 | v4 | v5 |
|---|---|---|
| import path | jwt/v4 | jwt/v5 |
| 標準クレーム | StandardClaims | RegisteredClaims(StandardClaims は削除) |
| Claims インターフェイス | Valid() error | getter 群 + ClaimsValidator |
| iat の検証 | デフォルトで検証 | デフォルト無効。WithIssuedAt で有効化 |
| 有効期限なしトークン | パスする | WithExpirationRequired で必須化可 |
| iss/aud/sub の検証 | 自前 | WithIssuer/WithAudience/WithSubject |
とくに刺さるのは iat 非検証への変更。v4 で iat の未来値を弾いていたコードは v5 ではすり抜けます。RFC 7519 上 iat は informational なので妥当な変更ですが、移行時はテストで挙動を必ず確認してください。
alg=noneと署名方式の偽装を塞ぐ
JWT 実装で踏む事故の代表が alg=none 攻撃と、署名方式の取り違えです。KeyFunc の書き方を間違えると一発で通ります。
アンチパターン: KeyFuncで方式を確認しない
// NG: token.Method を見ずに鍵を返す
_, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (any, error) {
return secret, nil
})
これだと攻撃者が {"alg":"none"} ヘッダで送ってきても検証ロジックを通してしまう余地が残ります。さらに公開鍵と HMAC 秘密鍵の取り違えで RS256→HS256 への取り替え攻撃も成立しえます。
OKパターン: WithValidMethodsで明示する
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}))
_, err := parser.ParseWithClaims(raw, &claims, func(t *jwt.Token) (any, error) {
return secret, nil
})
v5 では WithValidMethods を渡すだけで、許可した alg 以外は jwt.ErrTokenSignatureInvalid で落ちます。レガシー互換が要らなければこの一行で十分。鍵関数で token.Method.(*jwt.SigningMethodHMAC) の型アサートをかけるやり方も併用できます。
なお alg=none のトークンを許容するには jwt.UnsafeAllowNoneSignatureType を明示的に鍵として返す必要があり、名前のとおり危険。プロダクションで使う場面はありません。
ClaimsValidatorで業務固有の検証を足す
v5 では Valid() がインターフェイスから消えた代わりに、ClaimsValidator インターフェイス(Validate() error 一本)が追加されました。MIGRATION_GUIDE の “Migrating Application Specific Logic of the old Valid” 節に経緯が書かれています。標準検証は必ず動き、その上に独自検証が append される設計です。
func (c AppClaims) Validate() error {
if c.Role != "admin" && c.Role != "member" {
return errors.New("unknown role")
}
return nil
}
$ go test -run TestRoleValidation ./auth
ok example.com/auth 0.124s
v4 の Valid() オーバーライドは「標準検証ごと差し替えられる」のが怖い設計でした。v5 はこの抜け穴を構造で塞いでいる、と理解しておけば十分です。
リフレッシュトークンと失効設計
JWT 単独で「強制ログアウト」を実装することはできません。署名が正しい以上、サーバ側に状態を持たない限り無効化できないからです。実務で必要になるのは次のような分担です。
- アクセストークンは 15分以内の短命。失効はあきらめて期限切れに任せる
- リフレッシュトークンは DB に jti を保存。ログアウト時に該当行を消すか revoked フラグを立てる
- 署名鍵は kid ヘッダでローテーション。HS256 でもキーを複数持って
token.Header["kid"]から引く
「JWT はステートレスだから DB はいらない」という主張は、リフレッシュトークン側の状態管理を見落としています。ステートレスにできるのはアクセストークンの検証だけ、と切り分けて設計してください。Python 側の同等の実装は PyJWTで作るFastAPIのJWT認証—pwdlib対応とリフレッシュ実装 でまとめています。
まとめ
- v5 は
github.com/golang-jwt/jwt/v5。StandardClaimsは廃止、RegisteredClaimsに置き換える - 検証は
jwt.NewParserにWithValidMethods/WithLeeway/WithExpirationRequiredを渡して厳格化する alg=noneと署名方式の取り替え攻撃はWithValidMethodsで塞ぐ- 業務固有の検証は
ClaimsValidator(Validate() error)で書く。標準検証は外せない - 強制ログアウトはリフレッシュトークン側に状態を持たせて実装する
v5 への移行はビルドエラーを潰すだけなら30分で終わります。ただし iat 非検証や WithExpirationRequired を入れていない既存コードは振る舞いが変わっているので、テストで踏み直すのが安全です。

