Skip to content

JwtService.toEpoch / fromEpoch produce wrong epoch on non-UTC servers (regression from 3.5.0) #66

@deanmaunder

Description

@deanmaunder

Summary

Since 3.5.0, JwtService.toEpoch() and fromEpoch() produce incorrect epoch seconds on any server whose system timezone is not
UTC. All issued JWTs are stamped with iat / exp offset by the server's UTC offset (e.g. -10 hours on AEST / Australia/Sydney),
so tokens are immediately rejected by any validator using true UTC seconds.

Affected versions

  • 3.5.0
  • 3.6.0
  • 3.7.0
  • Confirmed clean: 3.4.3

Root cause

In models/jwt/JwtService.cfc, between 3.4.3 and 3.5.0, the epoch baseline string changed:

 function toEpoch( required target ){
     return dateDiff(
         "s",
-        dateConvert( "utc2local", "January 1 1970 00:00" ),
+        dateConvert( "utc2local", "1970-01-01T00:00:00Z" ),
         arguments.target
     );
 }

(fromEpoch has the same change.)

Lucee 6 parses the trailing Z as a UTC timezone marker, so the input Date is already shifted to local time before
dateConvert("utc2local", ...) runs. The local-offset shift is therefore applied twice:

  • Old string "January 1 1970 00:00" — parsed as naive local datetime. utc2local shifts once → baseline correctly represents
    the local equivalent of UTC midnight 1970-01-01. ✅
  • New string "1970-01-01T00:00:00Z" — parsed as UTC, Lucee returns the local representation of that instant (e.g. 1970-01-01 10:00 in AEST). utc2local shifts it again to 1970-01-01 20:00. ❌ Baseline is now +offset hours after the true epoch.

Since dateDiff("s", baseline, now()) uses that shifted baseline, every toEpoch(now()) returns (real epoch seconds) - offsetSeconds. On AEST (UTC+10) that's -36000s — exactly the symptom we see.

On a UTC server the offset is 0, so both code paths give the same result, which is why CI didn't catch it.

Reproduction

  1. Set the JVM / system timezone to anything other than UTC (e.g. TZ=Australia/Sydney).

  2. Run on any cbsecurity ≥ 3.5.0.

  3. Issue a token:

    var payload = getInstance("JwtService@cbsecurity").fromUser( user );
  4. Decode the token. iat will be currentRealEpoch - offsetSeconds, not currentRealEpoch.

  5. Any validator comparing against int(getTickCount()/1000) (or real UTC seconds) will immediately reject the token.

Impact

  • All JWTs unusable on any non-UTC deployment.
  • Symptom is "every authenticated request returns 401 immediately after login," which can be hard to diagnose because the token
    itself decodes and verifies cleanly — only its claims are wrong.

Suggested fix

Revert the baseline string to a form Lucee parses as naive local time, or parse explicitly as UTC and skip the dateConvert shift.
Two options:

// Option A — restore previous behaviour
dateConvert( "utc2local", "January 1 1970 00:00" )
// Option B — explicit and timezone-safe
parseDateTime( "1970-01-01T00:00:00Z" )   // already in local time, no further conversion

If Option B is used, drop the dateConvert( "utc2local", ... ) wrapper — it's the second shift that causes the bug.

A regression test running under TZ=America/New_York or TZ=Australia/Sydney would catch any future recurrence.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions