Skip to content

Fix 500 when a non-expiring API token is used as a raw bearer token - #1330

Merged
tomchop merged 1 commit into
mainfrom
fix/jwt-exp-null
Jul 30, 2026
Merged

tomchop merged 1 commit into
mainfrom
fix/jwt-exp-null

Conversation

@tomchop

@tomchop tomchop commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up from yeti-docker#29 — one of two bugs bundled in that report (the
other, the empty-Feeds-page issue, was mitigated in
yeti-platform/yeti-docker#30; this is the separate exp: null 500 also
mentioned there).

create_access_token() (core/schemas/user.py) always set the "exp"
claim on the JWT payload, encoding it as null whenever no
expires_delta was given. PyJWT's decode() only skips its expiration
check when the "exp" key is absent from the payload
(if "exp" in payload and options["verify_exp"]: in
jwt/api_jwt.py) — if the key is present but null,
_validate_exp() does int(payload["exp"]) and raises a raw
TypeError. That's not a PyJWTError, so none of the callers'
except jwt.PyJWTError: handlers catch it, and it becomes an unhandled
500.

This is reachable in practice:

  • create_api_key() — used by both the create-user CLI command and
    the /new-api-key endpoint — generates a non-expiring token by
    default (no expires_delta passed through).
  • get_current_user() (core/web/apiv2/auth.py:89) — the auth
    dependency behind every /api/v2/* endpoint — decodes any bearer
    token with PyJWT's default options (no verify_exp override), so
    presenting such a token directly as Authorization: Bearer <token>
    hits this path directly.

The /api-token exchange endpoint (login_api) was already unaffected
— it explicitly passes options={"verify_exp": False} and checks
expiration itself via the persisted RegisteredApiKey.expired.

Not a regression, not fixed by the jose→PyJWT migration: verified
both python-jose (as shipped in the currently released 2.5.0/2.5.1) and
PyJWT (current main) raise the identical uncaught TypeError on a
null exp claim.

Fix

Omit the "exp" claim entirely when there's no expiration, instead of
encoding it as null — the standard, correct way to express "no
expiration" in a JWT, which PyJWT already treats as "skip the
expiration check".

Test plan

  • Added test_api_key_used_directly_as_bearer to
    tests/apiv2/auth.py: uses a create_api_key()-issued token
    directly as a bearer token against /api/v2/auth/me, bypassing
    the /api-token exchange.
  • Verified it reproduces the exact TypeError traceback through
    get_current_user() on the pre-fix code, and passes with the fix.
  • Full tests/apiv2 suite: 199/201 pass (same 2 known pre-existing
    failures in tests/apiv2/tasks.py, unrelated).
  • tests/schemas (190/190) and tests/core_tests (29/29) pass
    unchanged.
  • ty check (core+yetictl and plugins jobs): 0 errors.
  • ruff check / ruff format --check: clean.

create_access_token() always set the "exp" claim, encoding it as null
whenever there's no expires_delta. PyJWT's decode() only skips its
expiration check when the "exp" key is absent from the payload; if the
key is present but null, _validate_exp() does int(payload["exp"]) and
raises a raw TypeError, which isn't a PyJWTError and so isn't caught by
any of the callers' `except jwt.PyJWTError` handlers, producing an
unhandled 500.

This is reachable in practice: create_api_key() (used by both the
"create-user" CLI command and the /new-api-key endpoint) generates a
non-expiring token by default, and get_current_user() -- the auth
dependency behind every /api/v2/* endpoint -- decodes any bearer token
with default options (no verify_exp override), so presenting such a
token directly hits this path. The /api-token exchange endpoint was
already unaffected: it explicitly passes options={"verify_exp": False}
and checks expiration itself via the persisted RegisteredApiKey.

Fix: omit the "exp" claim entirely when there's no expiration, instead
of encoding it as null -- the documented, correct way to express "no
expiration" in a JWT, which PyJWT already treats as "skip the check".

Verified with both python-jose (as used by the currently released
2.5.0/2.5.1) and PyJWT (current main): both raise the same uncaught
TypeError on a null exp claim, so this isn't specific to either library
or a regression from the jose->PyJWT migration.

Added a regression test that uses a create_api_key()-issued token
directly as a bearer token against a protected endpoint (bypassing the
/api-token exchange) -- verified it reproduces the exact TypeError
traceback through get_current_user() on the pre-fix code, and passes
with the fix.
@tomchop
tomchop merged commit c03820b into main Jul 30, 2026
5 checks passed
@tomchop
tomchop deleted the fix/jwt-exp-null branch July 30, 2026 11:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant