본문으로 건너뛰기

JWT 인증

이 챕터에서 다루는 것

JWT는 이 강의에서 처음 나온다. 라이브러리 사용법 이전에 서명이 무엇을 보장하고 무엇을 보장하지 않는지부터 정확히 잡는다. 그 이해가 없으면 라이브러리를 정확히 써도 안전하지 않다.

문제 — 서버가 상태를 들지 않고 "누구인지" 아는 법

로그인한 사용자를 기억하는 고전적인 방법은 세션이다. 서버가 세션 ID를 발급하고, 그 ID에 대응하는 사용자 정보를 서버 쪽 저장소에 들고 있는다.

문제는 모든 요청이 그 저장소를 읽어야 한다는 것이다. 서버가 여러 대면 공유 저장소(Redis 등)가 필요하고, 그것이 단일 장애점이 된다.

JWT의 답은 뒤집는 것이다. 사용자 정보를 토큰 안에 담아 클라이언트에게 준다. 서버는 저장소를 읽지 않고, 토큰에 붙은 서명만 확인해서 "이건 내가 발급한 게 맞다"를 판단한다.

이 거래에는 대가가 있다. 나중에 정리한다.

JWT의 세 조각

go run ./07-jwt-auth
--- 발급된 액세스 토큰 ---
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrbmQiOiJhY2Nlc3MiLCJyb2wiOiJhZG1pbiIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsInN1YiI6InVzZXItNDIiLCJhdWQiOlsidGFza3MtYXBpIl0sImV4cCI6MTc4NjUyNjEwMCwibmJmIjoxNzg2NTI1MjAwLCJpYXQiOjE3ODY1MjUyMDAsImp0aSI6Imp0aS0xIn0.6ieRsiQbiek6JAw8fcRd0ROlvrErzvMgtzhc2aBBKzo

--- 세 조각 ---
header {"alg":"HS256","typ":"JWT"}
payload {"knd":"access","rol":"admin","iss":"https://auth.example.com","sub":"user-42","aud":["tasks-api"],"exp":1786526100,"nbf":1786525200,"iat":1786525200,"jti":"jti-1"}
signature 6ieRsiQbiek6JAw8fcRd0ROlvrErzvMgtzhc2aBBKzo (43바이트, base64url)
페이로드는 암호화가 아니라 인코딩일 뿐이다. 비밀을 넣으면 그대로 읽힌다.

점 두 개로 나뉜 세 조각이다. 앞의 둘은 그냥 base64url로 인코딩된 JSON이다.

:::danger 서명은 위조를 막을 뿐 내용을 가리지 않는다 페이로드는 누구나 읽을 수 있다. 브라우저 콘솔에서 atob() 한 번이면 된다. 주민번호, 이메일, 내부 ID, 권한 목록 전체 — 노출되면 곤란한 것을 넣으면 안 된다.

JWT가 보장하는 것은 무결성(위조 불가)과 출처(내가 발급함)뿐이다. 기밀성은 보장하지 않는다. :::

테스트로도 못 박아 둔다.

examples/10-web-frameworks/07-jwt-auth/token/token_test.go
// TestPayloadIsNotEncrypted는 JWT의 가장 중요한 성질을 못 박는다.
// 서명은 위조를 막을 뿐 내용을 가리지 않는다. 비밀을 넣으면 안 된다.
func TestPayloadIsNotEncrypted(t *testing.T) {
iss := token.NewIssuer(cfgAt(base))
raw, err := iss.Access("user-42", "admin")
if err != nil {
t.Fatalf("발급: %v", err)
}

parts := strings.Split(raw, ".")
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("디코딩: %v", err)
}
if !strings.Contains(string(payload), `"rol":"admin"`) {
t.Errorf("페이로드에서 역할을 읽지 못했다: %s", payload)
}
}

설치

go get github.com/golang-jwt/jwt/v5@v5.3.1

v5는 v4에서 파서 옵션, 클레임 타입, 검증 API가 전부 바뀌었다. dgrijalva/jwt-go(원본)는 유지보수가 끊긴 지 오래다 — 그 이름을 쓰는 예제는 전부 오래된 것이다.

클레임 — RegisteredClaims를 임베딩한다

examples/10-web-frameworks/07-jwt-auth/token/token.go
// Claims는 이 서비스가 쓰는 클레임 집합이다.
//
// jwt.RegisteredClaims를 값으로 임베딩한다. 포인터로 임베딩하면
// ParseWithClaims가 nil을 역참조해 패닉이 난다 — 문서가 경고하는 지점이다.
type Claims struct {
Kind Kind `json:"knd"`
Role string `json:"rol"`
jwt.RegisteredClaims
}

jwt.RegisteredClaims가 RFC 7519의 표준 클레임을 담는다.

필드JSON
Issueriss발급자
Subjectsub주체 — 보통 사용자 ID
Audienceaud이 토큰을 받아야 할 대상
ExpiresAtexp만료 시각
NotBeforenbf이 시각 전에는 무효
IssuedAtiat발급 시각
IDjti토큰 고유 ID — 폐기 목록에 쓴다

시각 필드는 전부 *jwt.NumericDate다. 포인터인 이유는 "없음"과 "1970년 1월 1일"을 구분하기 위해서다 — 9-5의 그 이야기다.

커스텀 검증 — ClaimsValidator

examples/10-web-frameworks/07-jwt-auth/token/token.go
// Validate는 jwt.ClaimsValidator 구현이다.
// 표준 검증(exp, nbf, iss, aud)이 끝난 뒤 추가로 불린다.
func (c Claims) Validate() error {
switch c.Kind {
case KindAccess, KindRefresh:
default:
return fmt.Errorf("token: 알 수 없는 종류 %q", c.Kind)
}
if c.Subject == "" {
return errors.New("token: sub가 비어 있다")
}
return nil
}

Validate() error를 구현하면 v5가 표준 검증 뒤에 자동으로 부른다. 인터페이스 이름은 jwt.ClaimsValidator이고, 4-3에서 배운 대로 선언할 필요 없이 메서드만 있으면 만족된다.

발급

examples/10-web-frameworks/07-jwt-auth/token/token.go
func (i *Issuer) sign(kind Kind, subject, role string, ttl time.Duration) (string, error) {
now := i.cfg.now()
claims := Claims{
Kind: kind,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: i.cfg.Issuer,
Subject: subject,
Audience: jwt.ClaimStrings{i.cfg.Audience},
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
ID: i.cfg.newID(),
},
}

// HS256은 대칭 키다. 발급자와 검증자가 같은 서비스일 때 쓴다.
// 검증자가 여럿이면 RS256/ES256으로 공개 키만 나눠 준다.
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := t.SignedString(i.cfg.Secret)
if err != nil {
return "", fmt.Errorf("token: 서명 실패: %w", err)
}
return s, nil
}

SignedString(key any)가 받는 키 타입은 서명 방식에 따라 다르다. HMAC 계열은 []byte, RSA는 *rsa.PrivateKey, ECDSA는 *ecdsa.PrivateKey다. 타입이 any라 컴파일 타임에 안 걸린다.

검증 — 여기가 보안의 전부다

examples/10-web-frameworks/07-jwt-auth/token/token.go
// NewVerifier는 검증기를 만든다.
//
// WithValidMethods가 핵심이다. 이게 없으면 공격자가 헤더의 alg를
// "none"이나 "HS256"으로 바꿔 넣는 알고리즘 혼동 공격이 열린다.
func NewVerifier(cfg Config) *Verifier {
opts := []jwt.ParserOption{
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(cfg.Issuer),
jwt.WithAudience(cfg.Audience),
// exp가 없는 토큰을 거부한다. 기본값은 "없으면 무제한"이다.
jwt.WithExpirationRequired(),
// iat가 미래인 토큰을 거부한다.
jwt.WithIssuedAt(),
// 시계 차이를 몇 초 허용한다. 0이면 분산 환경에서 흔들린다.
jwt.WithLeeway(5 * time.Second),
}
if cfg.Now != nil {
// 테스트에서 만료를 흉내 내는 유일하게 깔끔한 방법이다.
opts = append(opts, jwt.WithTimeFunc(cfg.Now))
}
return &Verifier{secret: cfg.Secret, parser: jwt.NewParser(opts...)}
}

각 옵션이 막는 것:

옵션없으면
WithValidMethods알고리즘 혼동 공격. 아래에서 자세히
WithIssuer다른 시스템이 발급한 토큰이 통과한다
WithAudience다른 서비스용 토큰이 통과한다
WithExpirationRequiredexp가 없는 토큰이 영원히 유효하다
WithIssuedAt미래에 발급된 것으로 표시된 토큰이 통과한다
WithLeeway서버 간 시계 차이 1초에 인증이 흔들린다

v5의 전체 파서 옵션은 이렇다 — go doc github.com/golang-jwt/jwt/v5.ParserOption으로 확인할 수 있다: WithAllAudiences, WithAudience, WithExpirationRequired, WithIssuedAt, WithIssuer, WithJSONNumber, WithLeeway, WithNotBeforeRequired, WithPaddingAllowed, WithStrictDecoding, WithSubject, WithTimeFunc, WithValidMethods, WithoutClaimsValidation.

파싱은 이렇게 한다.

examples/10-web-frameworks/07-jwt-auth/token/token.go
// Verify는 서명과 클레임을 검증하고 Claims를 돌려준다.
func (v *Verifier) Verify(raw string, want Kind) (*Claims, error) {
claims := &Claims{}

// Keyfunc은 헤더를 보고 키를 고르는 콜백이다.
// kid를 쓰면 여기서 키를 갈아 끼울 수 있다.
_, err := v.parser.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) {
return v.secret, nil
})
if err != nil {
return nil, err
}
if claims.Kind != want {
return nil, fmt.Errorf("%w: %q를 기대했는데 %q", ErrWrongKind, want, claims.Kind)
}
return claims, nil
}

Keyfunc이 콜백인 이유는 키 교체(rotation) 때문이다. 헤더의 kid를 보고 그에 맞는 키를 돌려주면, 옛 키로 서명된 토큰과 새 키로 서명된 토큰이 공존할 수 있다.

알고리즘 혼동 공격

JWT 역사상 가장 유명한 취약점이다. 헤더의 alg공격자가 정한다는 데서 출발한다.

공격 1 — alg: none. "서명 없음"도 명세상 유효한 알고리즘이다. 검증 라이브러리가 헤더를 그대로 믿으면, 서명이 비어 있는 토큰이 통과한다.

공격 2 — RS256 → HS256. 서버가 RSA 공개 키로 검증한다고 하자. 공개 키는 말 그대로 공개되어 있다. 공격자가 헤더를 HS256으로 바꾸고 공개 키를 HMAC 비밀 키로 삼아 서명하면, 서버는 같은 공개 키로 HMAC 검증을 해서 통과시킨다.

두 공격 모두 답은 하나다. alg를 헤더에서 읽지 말고 서버가 정한다.

examples/10-web-frameworks/07-jwt-auth/token/token_test.go
// TestAlgNoneIsRejected는 WithValidMethods가 막는 것을 보여 준다.
// 이 옵션이 없으면 서명 없는 토큰이 통과할 수 있다.
func TestAlgNoneIsRejected(t *testing.T) {
cfg := cfgAt(base)

claims := token.Claims{
Kind: token.KindAccess,
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: cfg.Issuer,
Subject: "attacker",
Audience: jwt.ClaimStrings{cfg.Audience},
ExpiresAt: jwt.NewNumericDate(base.Add(time.Hour)),
},
}
unsigned, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).
SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("none 토큰 생성: %v", err)
}

_, err = token.NewVerifier(cfg).Verify(unsigned, token.KindAccess)
if err == nil {
t.Fatal("alg=none 토큰이 통과했다")
}
if !strings.Contains(err.Error(), "none") {
t.Errorf("err = %v — alg 거부 메시지를 기대했다", err)
}
}

:::tip golang-jwt v5는 기본이 안전한 편이다 v5는 alg: none을 쓰려면 jwt.UnsafeAllowNoneSignatureType이라는 이름의 상수를 명시해야 한다. 실수로 켜기 어렵게 만들어 놨다. 그래도 WithValidMethods항상 붙인다 — 문서도 강하게 권하고 있고, RS256↔HS256 혼동은 이 옵션이 아니면 막을 수 없다. :::

실행 결과 — 다섯 가지 실패

--- 검증 결과 ---
정상 통과 — sub=user-42 rol=admin knd=access exp=2026-08-12T09:15:00Z
16분 뒤 거부 — token has invalid claims: token is expired
페이로드 위조 거부 — token signature is invalid: signature is invalid
다른 키로 서명 거부 — token signature is invalid: signature is invalid
리프레시를 액세스로 거부 — token: 토큰 종류가 다르다: "access"를 기대했는데 "refresh"
리프레시를 리프레시로 통과 — sub=user-42 rol= knd=refresh exp=2026-08-26T09:00:00Z

"페이로드 위조" 는 페이로드의 "rol":"admin""rol":"root"로 바꾸고 서명은 그대로 둔 것이다. 서명은 헤더+페이로드 전체에 대해 계산되므로 한 글자만 바뀌어도 불일치한다. 이게 JWT가 실제로 보장하는 것이다.

에러를 구분한다

--- 에러 판별 ---
errors.Is(err, jwt.ErrTokenExpired) = true
errors.Is(err, jwt.ErrTokenSignatureInvalid) = false
클라이언트에게는 만료(재발급하라)와 서명 오류(다시 로그인하라)를 다르게 알려야 한다.

v5는 센티널 에러를 제대로 갖추고 있고 errors.Is(4-7)로 판별한다. 자주 쓰는 것들:

센티널언제
jwt.ErrTokenMalformed점이 두 개가 아니거나 base64가 깨졌다
jwt.ErrTokenSignatureInvalid서명 불일치
jwt.ErrTokenExpiredexp 지남
jwt.ErrTokenNotValidYetnbf 이전
jwt.ErrTokenInvalidIssueriss 불일치
jwt.ErrTokenInvalidAudienceaud 불일치
jwt.ErrTokenRequiredClaimMissingWithExpirationRequired인데 exp가 없다
jwt.ErrTokenInvalidClaims클레임 검증 실패 전반. 만료도 여기 감싸여 온다

만료는 ErrTokenExpiredErrTokenInvalidClaims 둘 다에 매치된다. errors.Is를 쓸 때 순서를 잘못 잡으면 만료를 일반 오류로 뭉갠다.

examples/10-web-frameworks/07-jwt-auth/token/token_test.go
func TestExpiredToken(t *testing.T) {
raw, err := token.NewIssuer(cfgAt(base)).Access("user-42", "admin")
if err != nil {
t.Fatalf("발급: %v", err)
}

// 16분 뒤의 시계로 검증한다. AccessTTL은 15분이다.
later := token.NewVerifier(cfgAt(base.Add(16 * time.Minute)))
_, err = later.Verify(raw, token.KindAccess)
if !errors.Is(err, jwt.ErrTokenExpired) {
t.Fatalf("err = %v, want ErrTokenExpired", err)
}
// 만료는 ErrTokenInvalidClaims로도 감싸여 온다. 둘 다 성립한다.
if !errors.Is(err, jwt.ErrTokenInvalidClaims) {
t.Errorf("ErrTokenInvalidClaims로도 감싸여야 한다: %v", err)
}
}

액세스와 리프레시

액세스 토큰의 수명은 짧아야 한다. 유출되면 만료까지 막을 방법이 없기 때문이다. 그런데 짧으면 사용자가 계속 다시 로그인해야 한다. 이 모순의 타협이 리프레시 토큰이다.

액세스리프레시
수명15분14일
쓰는 곳모든 API 요청/auth/refresh 하나
담는 것sub, rolsub
유출 시15분 뒤 무효폐기 목록이 필요
examples/10-web-frameworks/07-jwt-auth/token/token.go
// Access는 액세스 토큰을 발급한다.
func (i *Issuer) Access(subject, role string) (string, error) {
return i.sign(KindAccess, subject, role, i.cfg.AccessTTL)
}

// Refresh는 리프레시 토큰을 발급한다. 역할은 담지 않는다 —
// 갱신 시점에 다시 조회해야 권한 변경이 반영된다.
func (i *Issuer) Refresh(subject string) (string, error) {
return i.sign(KindRefresh, subject, "", i.cfg.RefreshTTL)
}

리프레시에 역할을 안 담는 이유가 중요하다. 담아 두면 관리자에서 일반 사용자로 강등된 사람이 14일 동안 관리자 권한으로 액세스 토큰을 계속 재발급받는다.

종류를 섞어 쓰지 못하게 막는 것도 필수다.

examples/10-web-frameworks/07-jwt-auth/token/token_test.go
// TestKindConfusion은 리프레시 토큰으로 API를 호출하지 못하게 막는지 본다.
func TestKindConfusion(t *testing.T) {
cfg := cfgAt(base)
refresh, err := token.NewIssuer(cfg).Refresh("user-42")
if err != nil {
t.Fatalf("발급: %v", err)
}

if _, err := token.NewVerifier(cfg).Verify(refresh, token.KindAccess); !errors.Is(err, token.ErrWrongKind) {
t.Errorf("err = %v, want ErrWrongKind", err)
}
if _, err := token.NewVerifier(cfg).Verify(refresh, token.KindRefresh); err != nil {
t.Errorf("리프레시 검증 실패: %v", err)
}
}

수명 14일짜리 리프레시 토큰으로 API를 호출할 수 있으면, 액세스 토큰을 15분으로 줄인 의미가 사라진다.

시계 주입 — 테스트와 결정성

examples/10-web-frameworks/07-jwt-auth/token/token.go
// Now는 시계다. nil이면 time.Now.
Now func() time.Time
// NewID는 jti를 만든다. nil이면 빈 jti.
NewID func() string

8-3과 9-3에서 배운 시계 주입이 여기서 두 가지 일을 한다.

  1. 만료 테스트. jwt.WithTimeFunc으로 검증 쪽 시계도 밀 수 있다. time.Sleep(16 * time.Minute) 같은 것은 논외다.
  2. 결정적 출력. HMAC은 같은 입력에 같은 출력을 낸다. 시계와 jti를 고정하면 서명 문자열까지 실행마다 같다. 그래서 위 실행 결과에 토큰 전문을 실을 수 있었다.
examples/10-web-frameworks/07-jwt-auth/token/token_test.go
func TestSignatureIsDeterministic(t *testing.T) {
iss := token.NewIssuer(cfgAt(base))

first, err := iss.Access("user-42", "admin")
if err != nil {
t.Fatalf("발급: %v", err)
}
second, err := iss.Access("user-42", "admin")
if err != nil {
t.Fatalf("발급: %v", err)
}
if first != second {
t.Error("같은 입력에 다른 토큰이 나왔다 — HMAC은 결정적이어야 한다")
}
if n := strings.Count(first, "."); n != 2 {
t.Errorf("점이 %d개, want 2 (header.payload.signature)", n)
}
}

실제 서비스에서는 NewIDcrypto/rand.Text()(9-2)를 부르게 한다. jti가 유일해야 폐기 목록이 동작한다.

저장 위치와 그 대가

프런트엔드가 토큰을 어디에 두느냐로 위험이 달라진다.

위치XSSCSRF비고
localStorage취약 — 스크립트가 읽는다안전가장 흔하고 가장 위험하다
메모리(JS 변수)취약하지만 노출 시간이 짧다안전새로고침하면 사라진다
HttpOnly 쿠키안전 — 스크립트가 못 읽는다취약9-7의 CrossOriginProtection 필요

정답은 없다. HttpOnly 쿠키 + CSRF 방어가 가장 견고하지만, 그러면 Authorization: Bearer가 아니라 쿠키 기반이 되고 모바일 클라이언트와 규약이 갈린다. 실무에서 흔한 절충은 액세스 토큰은 메모리, 리프레시 토큰은 HttpOnly 쿠키다.

JWT의 대가 — 폐기가 어렵다

앞에서 미룬 이야기다. 서버가 상태를 안 들고 있으므로, 발급한 토큰을 만료 전에 무효화할 수단이 없다.

  • 비밀번호를 바꿔도 기존 토큰은 유효하다.
  • 계정을 정지시켜도 액세스 토큰 만료까지는 접근된다.
  • 관리자에서 강등해도 토큰의 rol은 그대로다.

해법은 셋인데 전부 "상태를 다시 들이는" 방향이다.

  1. 액세스 토큰 수명을 짧게. 노출 창을 줄일 뿐 없애지는 못한다.
  2. jti 폐기 목록. 유효한 토큰이므로 목록이 작다. 다만 매 요청 조회가 생긴다.
  3. 매 요청 사용자 조회. 세션과 다를 게 없어지지만, 정확하다.

10-8은 3번을 쓴다. 인메모리 저장소라 조회가 공짜이고, 그 덕분에 역할 변경이 즉시 반영되고 탈퇴한 계정이 바로 막힌다. 실제 DB라면 캐시를 얹어야 한다.

:::note 그러면 JWT를 왜 쓰는가 매 요청 사용자 조회를 한다면 세션과 뭐가 다른가 — 정당한 질문이다. 차이는 "조회에 실패했을 때 무엇을 할 수 있느냐" 다. 세션 저장소가 죽으면 아무도 로그인 상태를 유지할 수 없다. JWT는 서명만으로 "이 사람은 user-42가 맞다"까지는 알 수 있어서, 축소된 기능으로라도 버틸 수 있다. 그리고 서비스가 여러 개일 때 각자 독립적으로 검증할 수 있다. 서비스 하나에 서버 몇 대인 상황이라면 세션이 더 단순하고 안전한 선택일 수 있다. :::

흔한 실수

1. WithValidMethods를 안 붙인다

알고리즘 혼동 공격이 열린다. 라이브러리 문서가 가장 강하게 경고하는 항목이다.

2. 페이로드에 비밀을 넣는다

암호화가 아니다. 개인정보, 내부 식별자, 권한 상세를 넣으면 그대로 노출된다.

3. exp를 안 넣는다

WithExpirationRequired가 없으면 영원히 유효한 토큰이 만들어진다.

4. RegisteredClaims를 포인터로 임베딩한다

ParseWithClaims가 nil을 역참조해 패닉이 난다. 문서가 명시적으로 경고한다.

5. 리프레시 토큰으로 API를 호출할 수 있게 둔다

액세스 토큰 수명을 짧게 잡은 의미가 사라진다. knd 같은 클레임으로 종류를 나눈다.

6. 리프레시 토큰에 역할을 담는다

강등된 사용자가 14일 동안 옛 권한으로 액세스 토큰을 재발급받는다.

7. 토큰 문자열을 로그에 남긴다

로그를 읽을 수 있는 사람이 그대로 로그인할 수 있다. 요청 ID만 남긴다.

8. 만료와 서명 오류를 같은 응답으로 낸다

클라이언트가 "재발급"과 "다시 로그인" 중 무엇을 할지 판단하지 못한다.

9. WithLeeway를 0으로 둔다

서버 간 시계가 1초만 달라도 방금 발급한 토큰이 nbf 위반으로 거부된다.

10. 비밀 키를 코드에 박는다

이 챕터의 예제는 데모용이라 그렇게 했고, 주석에 그렇게 적어 뒀다. 실제 키는 환경 변수나 시크릿 저장소에서 읽는다(파트 12-3).

정리

  • JWT는 무결성과 출처를 보장하고, 기밀성은 보장하지 않는다. 페이로드는 누구나 읽는다.
  • RegisteredClaims를 값으로 임베딩하고, 커스텀 클레임은 그 위에 얹는다. Validate() error를 붙이면 표준 검증 뒤에 자동으로 불린다.
  • WithValidMethods는 선택이 아니다. 알고리즘 혼동 공격을 막는 유일한 수단이다.
  • WithIssuer, WithAudience, WithExpirationRequired, WithLeeway를 함께 건다.
  • WithTimeFunc으로 검증 쪽 시계를 주입하면 만료를 Sleep 없이 테스트한다. 시계와 jti를 고정하면 서명까지 결정적이 된다.
  • 에러는 errors.Is로 판별한다. 만료는 ErrTokenExpiredErrTokenInvalidClaims 양쪽에 매치된다.
  • 액세스는 짧게 역할 포함, 리프레시는 길게 역할 제외. 종류를 클레임으로 나눠 서로 못 바꿔 쓰게 한다.
  • 폐기가 JWT의 근본적 약점이다. 짧은 수명, jti 폐기 목록, 매 요청 조회 — 전부 상태를 다시 들이는 방향이다.

연습문제

  1. Keyfunc이 헤더의 kid를 보고 키를 고르게 바꿔 보자. v5에는 헤더를 넣는 옵션이 없다 — jwt.NewWithClaims가 돌려준 *jwt.TokenHeader 맵에 직접 넣어야 한다(go doc github.com/golang-jwt/jwt/v5.Token으로 필드를 확인한다. TokenOption은 이름만 있고 실제 옵션이 없다). 옛 키로 발급한 토큰과 새 키로 발급한 토큰이 동시에 통과하는지 테스트해 보자. Keyfunc이 모르는 kid를 받으면 무엇을 반환해야 하는가?

  2. jti 폐기 목록을 만들어 보자. Verifierrevoked map[string]time.Time을 두고 Verify에서 확인한다. 만료된 항목은 언제 지우는가? 7-5의 어떤 도구가 필요한가? -race로 확인한다.

  3. HS256을 RS256으로 바꿔 보자. jwt.ParseRSAPrivateKeyFromPEMjwt.ParseRSAPublicKeyFromPEM을 쓴다. 그다음 공개 키를 HMAC 비밀 키로 삼아 HS256으로 서명한 토큰을 만들어 검증기에 넣어 보자. WithValidMethods[]string{"RS256"}으로 뒀을 때와 뺐을 때 결과가 어떻게 다른가?