From 9ac46c3ed1abff7be25bbb95255584f4688fbc46 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 19 Aug 2026 11:57:06 +0700 Subject: [PATCH 1/5] fix(api): require exp claim when verifying access tokens --- livekit-api/livekit/api/access_token.py | 8 +++++++- tests/api/test_access_token.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/livekit-api/livekit/api/access_token.py b/livekit-api/livekit/api/access_token.py index e6516819..1df12d04 100644 --- a/livekit-api/livekit/api/access_token.py +++ b/livekit-api/livekit/api/access_token.py @@ -233,13 +233,19 @@ def verify(self, token: str, *, verify_signature: bool = True) -> Claims: if verify_signature and (not self.api_key or not self.api_secret): raise ValueError("api_key and api_secret must be set") + options = {"verify_signature": verify_signature} + # First-party minters always set exp. Without this, a hand-rolled token + # with a valid signature and no exp verifies forever (livekit/protocol#1706). + if verify_signature: + options["require"] = ["exp"] + claims = jwt.decode( token, key=self.api_secret or "", issuer=self.api_key or "", algorithms=["HS256"], leeway=self._leeway.total_seconds(), - options={"verify_signature": verify_signature}, + options=options, ) video_dict = claims.get("video", dict()) video_dict = {camel_to_snake(k): v for k, v in video_dict.items()} diff --git a/tests/api/test_access_token.py b/tests/api/test_access_token.py index 85aeb6ce..df5f4678 100644 --- a/tests/api/test_access_token.py +++ b/tests/api/test_access_token.py @@ -1,5 +1,7 @@ +import calendar import datetime +import jwt import pytest from livekit.api import AccessToken, TokenVerifier, VideoGrants, SIPGrants from livekit.protocol.room import RoomConfiguration @@ -101,3 +103,16 @@ def test_verify_token_expired() -> None: ) with pytest.raises(Exception): token_verifier.verify(token) + + +def test_verify_token_missing_exp() -> None: + now = calendar.timegm(datetime.datetime.now(datetime.timezone.utc).utctimetuple()) + token = jwt.encode( + {"sub": "test_identity", "iss": TEST_API_KEY, "nbf": now}, + TEST_API_SECRET, + algorithm="HS256", + ) + + token_verifier = TokenVerifier(TEST_API_KEY, TEST_API_SECRET) + with pytest.raises(Exception): + token_verifier.verify(token) From 0afbb42532d28c66a47ee1b8a32349dc4b12c5e8 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 19 Aug 2026 12:00:22 +0700 Subject: [PATCH 2/5] fix(api): type TokenVerifier jwt options for mypy --- livekit-api/livekit/api/access_token.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/livekit-api/livekit/api/access_token.py b/livekit-api/livekit/api/access_token.py index 1df12d04..d35fded4 100644 --- a/livekit-api/livekit/api/access_token.py +++ b/livekit-api/livekit/api/access_token.py @@ -19,7 +19,7 @@ import jwt import dataclasses from dataclasses import dataclass -from typing import Optional, List, Literal +from typing import Any, Optional, List, Literal from google.protobuf.json_format import MessageToDict, ParseDict from livekit.protocol.room import RoomConfiguration @@ -233,7 +233,7 @@ def verify(self, token: str, *, verify_signature: bool = True) -> Claims: if verify_signature and (not self.api_key or not self.api_secret): raise ValueError("api_key and api_secret must be set") - options = {"verify_signature": verify_signature} + options: dict[str, Any] = {"verify_signature": verify_signature} # First-party minters always set exp. Without this, a hand-rolled token # with a valid signature and no exp verifies forever (livekit/protocol#1706). if verify_signature: From ab5685660840030b03ca134e6b77912c416f8bd7 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 19 Aug 2026 12:04:31 +0700 Subject: [PATCH 3/5] fix(api): pass jwt decode options as typed literals --- livekit-api/livekit/api/access_token.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/livekit-api/livekit/api/access_token.py b/livekit-api/livekit/api/access_token.py index d35fded4..0f8cbd46 100644 --- a/livekit-api/livekit/api/access_token.py +++ b/livekit-api/livekit/api/access_token.py @@ -19,7 +19,7 @@ import jwt import dataclasses from dataclasses import dataclass -from typing import Any, Optional, List, Literal +from typing import Optional, List, Literal from google.protobuf.json_format import MessageToDict, ParseDict from livekit.protocol.room import RoomConfiguration @@ -233,11 +233,13 @@ def verify(self, token: str, *, verify_signature: bool = True) -> Claims: if verify_signature and (not self.api_key or not self.api_secret): raise ValueError("api_key and api_secret must be set") - options: dict[str, Any] = {"verify_signature": verify_signature} # First-party minters always set exp. Without this, a hand-rolled token # with a valid signature and no exp verifies forever (livekit/protocol#1706). - if verify_signature: - options["require"] = ["exp"] + decode_options = ( + {"verify_signature": True, "require": ["exp"]} + if verify_signature + else {"verify_signature": False} + ) claims = jwt.decode( token, @@ -245,7 +247,7 @@ def verify(self, token: str, *, verify_signature: bool = True) -> Claims: issuer=self.api_key or "", algorithms=["HS256"], leeway=self._leeway.total_seconds(), - options=options, + options=decode_options, ) video_dict = claims.get("video", dict()) video_dict = {camel_to_snake(k): v for k, v in video_dict.items()} From 7d0ab18ce0db6e9e9769cd933b4b84c3b1c84f57 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 19 Aug 2026 12:19:54 +0700 Subject: [PATCH 4/5] fix(api): inline jwt decode options for mypy Options A shared options dict from a ternary widens to dict[str, object] and fails livekit-api type-check against PyJWT Options. --- livekit-api/livekit/api/access_token.py | 34 +++++++++++++++---------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/livekit-api/livekit/api/access_token.py b/livekit-api/livekit/api/access_token.py index 0f8cbd46..fc5684ed 100644 --- a/livekit-api/livekit/api/access_token.py +++ b/livekit-api/livekit/api/access_token.py @@ -235,20 +235,26 @@ def verify(self, token: str, *, verify_signature: bool = True) -> Claims: # First-party minters always set exp. Without this, a hand-rolled token # with a valid signature and no exp verifies forever (livekit/protocol#1706). - decode_options = ( - {"verify_signature": True, "require": ["exp"]} - if verify_signature - else {"verify_signature": False} - ) - - claims = jwt.decode( - token, - key=self.api_secret or "", - issuer=self.api_key or "", - algorithms=["HS256"], - leeway=self._leeway.total_seconds(), - options=decode_options, - ) + # Inline each options literal so mypy matches PyJWT's Options TypedDict + # (a shared dict from a ternary widens to dict[str, object]). + if verify_signature: + claims = jwt.decode( + token, + key=self.api_secret or "", + issuer=self.api_key or "", + algorithms=["HS256"], + leeway=self._leeway.total_seconds(), + options={"verify_signature": True, "require": ["exp"]}, + ) + else: + claims = jwt.decode( + token, + key=self.api_secret or "", + issuer=self.api_key or "", + algorithms=["HS256"], + leeway=self._leeway.total_seconds(), + options={"verify_signature": False}, + ) video_dict = claims.get("video", dict()) video_dict = {camel_to_snake(k): v for k, v in video_dict.items()} video_dict = {k: v for k, v in video_dict.items() if k in VideoGrants.__dataclass_fields__} From c62913aa28a4419a2726fd5bbdb0387de2372aa3 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 19 Aug 2026 15:55:50 +0700 Subject: [PATCH 5/5] fix(api): drop the mypy options comment --- livekit-api/livekit/api/access_token.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/livekit-api/livekit/api/access_token.py b/livekit-api/livekit/api/access_token.py index fc5684ed..4f4d1658 100644 --- a/livekit-api/livekit/api/access_token.py +++ b/livekit-api/livekit/api/access_token.py @@ -235,8 +235,6 @@ def verify(self, token: str, *, verify_signature: bool = True) -> Claims: # First-party minters always set exp. Without this, a hand-rolled token # with a valid signature and no exp verifies forever (livekit/protocol#1706). - # Inline each options literal so mypy matches PyJWT's Options TypedDict - # (a shared dict from a ternary widens to dict[str, object]). if verify_signature: claims = jwt.decode( token,