GoでJWT認証ミドルウェアを書く—golang-jwt v5とalg=none対策

GoでJWT認証ミドルウェアを書く—golang-jwt v5とalg=none対策 | mohablog

golang-jwt/jwt は Go で JWT を扱うときのデファクトです。ただし v5StandardClaims が消え、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.ErrTokenExpiredjwt.ErrTokenNotValidYetjwt.ErrTokenSignatureInvalidjwt.ErrTokenMalformed など細粒度の sentinel error に分けています。文字列比較ではなく errors.Is で判定。ログに種別を残すと、攻撃と単なる期限切れの切り分けが楽になります。

v4からv5への移行で詰まりやすい点

差分は MIGRATION_GUIDE の “Changes to the Claims interface” 節に網羅されています。実装で踏むものに絞って整理しました。

項目v4v5
import pathjwt/v4jwt/v5
標準クレームStandardClaimsRegisteredClaims(StandardClaims は削除)
Claims インターフェイスValid() errorgetter 群 + 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/v5StandardClaims は廃止、RegisteredClaims に置き換える
  • 検証は jwt.NewParserWithValidMethods/WithLeeway/WithExpirationRequired を渡して厳格化する
  • alg=none と署名方式の取り替え攻撃は WithValidMethods で塞ぐ
  • 業務固有の検証は ClaimsValidator(Validate() error)で書く。標準検証は外せない
  • 強制ログアウトはリフレッシュトークン側に状態を持たせて実装する

v5 への移行はビルドエラーを潰すだけなら30分で終わります。ただし iat 非検証や WithExpirationRequired を入れていない既存コードは振る舞いが変わっているので、テストで踏み直すのが安全です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次