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
-
Set the JVM / system timezone to anything other than UTC (e.g. TZ=Australia/Sydney).
-
Run on any cbsecurity ≥ 3.5.0.
-
Issue a token:
var payload = getInstance("JwtService@cbsecurity").fromUser( user );
-
Decode the token. iat will be currentRealEpoch - offsetSeconds, not currentRealEpoch.
-
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.
Summary
Since 3.5.0,
JwtService.toEpoch()andfromEpoch()produce incorrect epoch seconds on any server whose system timezone is notUTC. All issued JWTs are stamped with
iat/expoffset 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
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 ); }(
fromEpochhas the same change.)Lucee 6 parses the trailing
Zas a UTC timezone marker, so the input Date is already shifted to local time beforedateConvert("utc2local", ...)runs. The local-offset shift is therefore applied twice:"January 1 1970 00:00"— parsed as naive local datetime.utc2localshifts once → baseline correctly representsthe local equivalent of UTC midnight 1970-01-01. ✅
"1970-01-01T00:00:00Z"— parsed as UTC, Lucee returns the local representation of that instant (e.g.1970-01-01 10:00in AEST).utc2localshifts it again to1970-01-01 20:00. ❌ Baseline is now+offsethours after the true epoch.Since
dateDiff("s", baseline, now())uses that shifted baseline, everytoEpoch(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
Set the JVM / system timezone to anything other than UTC (e.g.
TZ=Australia/Sydney).Run on any cbsecurity ≥ 3.5.0.
Issue a token:
var payload = getInstance("JwtService@cbsecurity").fromUser( user );Decode the token.
iatwill becurrentRealEpoch - offsetSeconds, notcurrentRealEpoch.Any validator comparing against
int(getTickCount()/1000)(or real UTC seconds) will immediately reject the token.Impact
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
dateConvertshift.Two options:
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_YorkorTZ=Australia/Sydneywould catch any future recurrence.