Skip to content

feat(jwt): add leeway support for token expiration verification#4643

Open
Krishnachaitanyakc wants to merge 11 commits intolitestar-org:mainfrom
Krishnachaitanyakc:feat/jwt-leeway-support
Open

feat(jwt): add leeway support for token expiration verification#4643
Krishnachaitanyakc wants to merge 11 commits intolitestar-org:mainfrom
Krishnachaitanyakc:feat/jwt-leeway-support

Conversation

@Krishnachaitanyakc
Copy link
Copy Markdown

@Krishnachaitanyakc Krishnachaitanyakc commented Mar 25, 2026

Summary

Adds support for the leeway parameter in JWT token decoding, as requested in #4584.

  • Added a leeway field (type float | timedelta, default 0) to BaseJWTAuth, JWTAuth, JWTCookieAuth, and OAuth2PasswordBearerAuth
  • Threaded leeway through JWTAuthenticationMiddleware and JWTCookieAuthenticationMiddleware to Token.decode and Token.decode_payload
  • Token.decode_payload passes leeway directly to PyJWT's jwt.decode(), which natively supports it
  • Maintained backward compatibility: if a subclass overrides decode_payload without the leeway parameter, the call falls back to the old signature via a TypeError catch
  • When leeway is set, Token.decode bypasses __post_init__ validation (which rejects expired exp values) since PyJWT already validated expiry with leeway applied

Closes #4584

Test plan

  • Added test_decode_with_leeway_allows_recently_expired_token -- verifies a token expired by 5 seconds is accepted with 10 seconds of leeway (parametrized over int, float, and timedelta)
  • Added test_decode_with_leeway_does_not_allow_long_expired_token -- verifies a token expired by 1 hour is still rejected with 10 seconds of leeway
  • All 164 existing JWT tests pass (including test_custom_decode_payload for backward compatibility)

📚 Documentation preview 📚: https://litestar-org.github.io/litestar-docs-preview/4643

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.36%. Comparing base (20ce756) to head (841ccf4).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4643      +/-   ##
==========================================
+ Coverage   67.32%   67.36%   +0.03%     
==========================================
  Files         292      292              
  Lines       14925    14941      +16     
  Branches     1673     1673              
==========================================
+ Hits        10049    10065      +16     
  Misses       4739     4739              
  Partials      137      137              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Member

@sobolevn sobolevn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Krishnachaitanyakc
Copy link
Copy Markdown
Author

@sobolevn Thanks for the pointer to the InitVar approach! I've reworked the implementation following your suggestion:

What changed:

  • leeway is now an InitVar[float] on Token, so it participates in __post_init__ validation but is not stored as an instance attribute
  • __post_init__ applies the leeway tolerance when checking exp, allowing recently-expired tokens within the leeway window to pass validation
  • decode() converts timedelta leeway to float seconds and passes it directly to cls(**payload, leeway=leeway_seconds)
  • Removed msgspec.convert() — the Token is now constructed via its own __init__, which naturally triggers __post_init__ with the leeway
  • Removed _construct_without_validation() — no longer needed since __post_init__ itself handles leeway
  • Removed the TypeError catch around decode_payload — that backward-compat path is no longer necessary
  • Dropped the msgspec import from this module entirely

Copy link
Copy Markdown
Member

@JacobCoffee JacobCoffee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to get CI all passing as well :)

Krishnachaitanyakc and others added 11 commits March 26, 2026 10:51
Add a `leeway` parameter to JWT auth configuration, middleware, and token
decoding to allow a time margin (in seconds or as a timedelta) for clock
skew when verifying `exp` and `nbf` claims. This delegates to PyJWT's
built-in leeway support.

Closes litestar-org#4584
- Extract _build_decode_options and _construct_without_validation from
  Token.decode to reduce cyclomatic complexity below the C901 threshold
- Add missing leeway parameter to CustomToken.decode_payload override
  in tests to match the updated base class signature
Replace :func:`jwt.decode` with ``jwt.decode`` to avoid Sphinx
cross-reference warning since PyJWT is not in the intersphinx mapping.
Covers the except TypeError fallback when a subclass overrides
decode_payload without the leeway parameter, improving patch coverage.
…test

Add type: ignore[override] and pyright: ignore[reportIncompatibleMethodOverride]
to the LegacyToken.decode_payload test override, which intentionally omits
the leeway parameter to test backward compatibility.
…dependency

Replace msgspec.convert() with direct cls(**payload, leeway=...) construction
now that leeway is an InitVar handled in __post_init__. This eliminates:
- The _construct_without_validation bypass method
- The TypeError catch for backward-compat decode_payload calls
- The msgspec import and ValidationError in the except clause

The leeway InitVar approach (per sobolevn's suggestion) lets __post_init__
apply the leeway tolerance to exp validation directly, so there is no need
to skip validation for recently-expired tokens.
…class signature

The Token dataclass has an InitVar[float] leeway field, which means
__post_init__ receives it as a parameter. The CustomToken subclass in
the test was overriding __post_init__ without this parameter, causing
a mypy [override] error.
- Remove default value from CustomToken.__post_init__ leeway param
  (defaults should be on InitVar field, not __post_init__ parameter)
- Pass random_field as int since msgspec type coercion is no longer used
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement: support leeway in jwt

3 participants