From ff09d63bd0d6365630378899c826ca93ce3432d3 Mon Sep 17 00:00:00 2001 From: cipherprofessor Date: Sun, 20 Sep 2026 12:24:44 +0530 Subject: [PATCH] fix(auth): validate iat claim when verifying access tokens APIKeyTokenVerifier.Verify() called jwt.ParseWithClaims without jwt.WithIssuedAt(), so the iat claim was never validated. A token correctly signed with the API secret but carrying a far-future iat, with no nbf claim, verified successfully immediately regardless of how far in the future it claimed to have been issued. First-party SDKs always set nbf (see AccessToken.ToJWT), which the parser already validates by default when present, so this only mattered for hand-rolled or third-party-minted tokens that omit nbf. Adds jwt.WithIssuedAt() alongside the existing jwt.WithExpirationRequired() (added in #1706 for a related but distinct gap). Confirmed against the vendored golang-jwt/v5 v5.3.1 source that this does not require iat to be present -- verifyIssuedAt is called with required=false, so a token that omits iat entirely is completely unaffected, and existing SDK-minted and hand-rolled test tokens without an iat claim continue to verify exactly as before. A token that omits both iat and nbf remains unaffected by this check either way, since iat is still not required -- that's an intentional, narrower boundary matching this issue's own scope, not something missed. webhook/verifier.go's Receive() calls the same APIKeyTokenVerifier.Verify, so it inherits this fix with no separate change needed; confirmed there's no parallel jwt.ParseWithClaims call anywhere else in the repo. Added a regression test modeling a hand-rolled token with iat 2 hours in the future and no nbf claim, following the existing "token without exp is rejected" sibling test's style. Confirmed it fails against pre-fix code and passes after. go build, go vet, gofmt, and the full auth package test suite are all clean. One pre-existing, unrelated test failure exists in the webhook package (TestURLNotifierFilter/none, a flaky require.Eventually-style timeout) -- confirmed identical on unmodified main via stash-and-rerun. Three pre-existing gofmt violations exist elsewhere in the repo (auth/grants.go, tools/tools.go, utils/jwtutil/jwtutil.go) -- confirmed identical on unmodified main, untouched by this diff. Fixes #1710 --- .changeset/auth-verify-issued-at.md | 6 ++++++ auth/verifier.go | 7 +++++++ auth/verifier_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 .changeset/auth-verify-issued-at.md diff --git a/.changeset/auth-verify-issued-at.md b/.changeset/auth-verify-issued-at.md new file mode 100644 index 000000000..6b10ef9ee --- /dev/null +++ b/.changeset/auth-verify-issued-at.md @@ -0,0 +1,6 @@ +--- +"github.com/livekit/protocol": patch +"@livekit/protocol": patch +--- + +Validate the `iat` claim when verifying access tokens (`auth.APIKeyTokenVerifier.Verify`). Previously, a correctly-signed token that omitted `nbf` and carried a far-future `iat` would verify successfully immediately, regardless of how far in the future it claimed to have been issued. diff --git a/auth/verifier.go b/auth/verifier.go index 60a476d35..8e615712f 100644 --- a/auth/verifier.go +++ b/auth/verifier.go @@ -82,6 +82,13 @@ func (v *APIKeyTokenVerifier) Verify(key interface{}) (*jwt.RegisteredClaims, *C // or third-party minter that forgets it silently mints a permanent // credential. jwt.WithExpirationRequired(), + // Without this, iat is never validated. First-party SDKs always set + // nbf (see AccessToken.ToJWT), which the parser already validates by + // default when present, but a token that omits nbf entirely verifies + // immediately regardless of how far in the future it claims to have + // been issued unless iat is checked too. (A token omitting both iat + // and nbf is unaffected by this check either way.) + jwt.WithIssuedAt(), ) if err != nil { return nil, nil, err diff --git a/auth/verifier_test.go b/auth/verifier_test.go index dd5d23c71..28414a0a9 100644 --- a/auth/verifier_test.go +++ b/auth/verifier_test.go @@ -69,6 +69,31 @@ func TestVerifier(t *testing.T) { require.Error(t, err) }) + t.Run("token issued in the future without nbf is rejected", func(t *testing.T) { + // hand-rolled JWT with iat 2h in the future and no nbf claim. The Go + // SDK always sets nbf (see AccessToken.ToJWT), so build this directly + // to model a third-party minter that omits it: without nbf, and + // without WithIssuedAt() on the parser, nothing stops a token from + // verifying immediately no matter how far in the future it claims to + // have been issued. + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": apiKey, + "iat": jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), + "exp": jwt.NewNumericDate(time.Now().Add(3 * time.Hour)), + "video": map[string]interface{}{ + "roomCreate": true, + }, + }) + authToken, err := token.SignedString([]byte(secret)) + require.NoError(t, err) + + v, err := auth.ParseAPIToken(authToken) + require.NoError(t, err) + + _, _, err = v.Verify(secret) + require.Error(t, err) + }) + t.Run("unexpired token is verified", func(t *testing.T) { claim := auth.VideoGrant{RoomCreate: true} at := auth.NewAccessToken(apiKey, secret).