Skip to content

refactor: contain the JWT::$leeway static, and restore it after each decode - #61

Merged
turegjorup merged 2 commits into
developfrom
feature/contain-jwt-leeway-static
Aug 26, 2026
Merged

refactor: contain the JWT::$leeway static, and restore it after each decode#61
turegjorup merged 2 commits into
developfrom
feature/contain-jwt-leeway-static

Conversation

@turegjorup

@turegjorup turegjorup commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

firebase/php-jwt exposes clock-skew tolerance only as JWT::$leeway, a process-global static with no per-call alternative. Two problems follow from that, one inward and one outward.

Inward: the invariant had no home

The write already sat immediately before the decode, but only a comment said it had to, and nothing stopped a future edit from putting a cache read or an HTTP call between them.

decodeWithLeeway() now does nothing but set the static and decode, so the constraint has a single home and a stated rationale: no suspension point may come between the two, which is why the verification keys are resolved by the caller before it is entered.

Under PHP-FPM, where a request owns its process, that ordering is a formality. Under cooperative concurrency it is what stops a fibre decoding with a sibling provider's leeway, and under a preemptive model it would not be sufficient at all — noted in the docblock so a future move to a worker runtime has something to find.

Outward: the static stayed written

Under PHP-FPM the mutated static dies with the request, so this was invisible. In a worker process it persists for the life of the process and silently applies to any other firebase/php-jwt consumer in it that never sets its own leeway — a token that should have been rejected as expired could be accepted, because this library happened to run first with a laxer setting.

The previous value is now restored in a finally, so a rejected token cannot leave our leeway applied either. Writing a process-global is unavoidable given the upstream API; leaving it written is not.

Behaviour inside this library is unchanged.

Tests

The suite has carried MockJWT::$leeway for exactly this purpose and never asserted it. Now three things are pinned:

  • the configured leeway is in effect during the decode, observed from inside the mocked decode() rather than after the fact
  • a pre-existing value is restored on the success path
  • and restored on the failure path, via a SignatureInvalidException

Asserting from inside the decode is what makes the restore testable at all — checking the static afterwards can only ever see the restored value.

Verification

137 tests, all green. Coverage 100% (31/31 methods, 194/194 lines). Mutation: 241 generated, 237 killed, 4 ignored, zero escaped, MSI 100%. PHPStan max clean at the ceiling and at the dependency floor, php-cs-fixer, markdownlint, prettier and composer normalize all clean.

Note on the return type

The extraction needed \stdClass rather than object as the return type: object erased the narrowing JWT::decode() provides and PHPStan lost $claims->aud in the caller.

firebase/php-jwt exposes leeway only as JWT::$leeway, a process-global
static with no per-call alternative. The write already sat immediately
before the decode, but only a comment said it had to, and nothing stopped a
future edit from putting a cache read or an HTTP call between them.

decodeWithLeeway() now does nothing but set the static and decode, so the
invariant has a single home and a stated rationale: no suspension point may
come between the two, which is why the verification keys are resolved by the
caller before it is entered. Under PHP-FPM that ordering is a formality;
under cooperative concurrency it is what stops a fibre decoding with a
sibling provider's leeway, and under a preemptive model it would not be
enough at all.

Behaviour is unchanged. The test suite already carried MockJWT::$leeway for
this and never asserted it, so there is now a test pinning the configured
leeway to what reaches the decode.

The extraction also needed the return type spelled as \stdClass rather than
object: `object` erased the narrowing JWT::decode() provides and PHPStan lost
$claims->aud.
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (507b1be) to head (df212a4).

Additional details and impacted files
@@             Coverage Diff             @@
##             develop       #61   +/-   ##
===========================================
  Coverage     100.00%   100.00%           
- Complexity        87        88    +1     
===========================================
  Files              1         1           
  Lines            220       224    +4     
===========================================
+ Hits             220       224    +4     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 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.

Setting the process-global is unavoidable — firebase/php-jwt offers no
per-call leeway — but leaving it set is a choice. Under PHP-FPM the mutated
static dies with the request. In a worker process it persists for the life of
the process and silently applies to every other firebase/php-jwt consumer in
it that never sets its own leeway, which makes this library a bad neighbour
in exactly the runtime it otherwise suits.

The restore is in a finally block, so a rejected token cannot leave our
leeway applied either. Both paths are tested: the value in effect during the
decode, and the value left behind after a success and after a
SignatureInvalidException.
@turegjorup turegjorup changed the title refactor: contain the JWT::$leeway static write in one method refactor: contain the JWT::$leeway static, and restore it after each decode Aug 26, 2026
@turegjorup turegjorup self-assigned this Aug 26, 2026
@turegjorup
turegjorup merged commit 345d002 into develop Aug 26, 2026
18 checks passed
@turegjorup
turegjorup deleted the feature/contain-jwt-leeway-static branch August 26, 2026 11:05
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