Trusting the clock: how licensing survives date rollback
Almost every time-limited license comes down to one comparison: is now past the expiry date? The trouble is the word now. On the user's device, "now" is whatever the system clock says, and the system clock is fully under
Almost every time-limited license comes down to one comparison: is now past the expiry date? The trouble is the word now. On the user's device, "now" is whatever the system clock says, and the system clock is fully under the user's control. Rolling it back to last month is the oldest and most reliable way to revive an expired trial or a lapsed subscription — and a licensing check that trusts DateTime.UtcNow walks straight into it. Defending expiry means treating local time as a hint, not a fact.
Why the naive check fails
The tempting implementation is also the broken one:
// DON'T: trusts an attacker-controlled clock as the only source of time.
if (DateTime.UtcNow > license.ExpiryUtc)
Deactivate();
Nothing here is wrong as code; the flaw is the trust model. DateTime.UtcNow reads the OS clock, and the user can set the OS clock to anything. The moment the trial ends, they set the date back to the install day and the comparison flips to "valid" forever. Worse, it fails silently — there is no error, no tamper signal, just a license that quietly never expires. Any expiry logic that has only the local clock as input has already lost; you need a source of time the user cannot rewind.
The high-water mark: time only moves forward
You cannot stop a user from changing their clock, but you can notice when time appears to run backwards. Keep a persisted high-water mark — the latest instant the app has ever observed — and advance it, never retreat it. On each check, if the live clock is earlier than the high-water mark, the clock has been wound back, and you use the high-water mark as the effective "now":
DateTime HighWaterMark = LoadPersistedHighWater(); // last time we ever saw
DateTime EffectiveNow()
{
var clock = DateTime.UtcNow;
if (clock < HighWaterMark)
return HighWaterMark; // clock went backwards → don't trust it
HighWaterMark = clock; // advance and persist
PersistHighWater(HighWaterMark);
return clock;
}
// expiry check now uses EffectiveNow(), not the raw clock
if (EffectiveNow() > license.ExpiryUtc)
Deactivate();
Now rolling the clock back does nothing useful: the effective time never drops below the furthest point the app has already reached. If a trial has run for 25 of 30 days, the high-water mark sits at day 25, and setting the clock to day 1 leaves the effective time at day 25. Expiry still arrives on schedule.
A few details make this robust. Persist the high-water mark somewhere not obvious and not tied to a single file the user can delete to reset it — the same signed, tamper-evident store that holds the license lease is a good home. Advance it from every trusted time source you see, not just app launch: each online check-in is an opportunity to push it forward with authoritative time. And allow a small tolerance (a few minutes) so ordinary clock drift and NTP corrections don't register as attacks.
Server time is the authority
The high-water mark defends offline, but it is a ratchet, not a clock — it knows time moved forward, not the true date. The authoritative answer comes from the server on check-in. When the app contacts the license service to validate or renew, the response carries the server's time, and that is the real "now": it cannot be altered on the device, and it both advances the high-water mark and anchors the next offline window.
This is why a signed, time-boxed lease pairs so well with the ratchet. The server issues a lease valid for a bounded window and stamped with server time; between check-ins the app runs offline, using the high-water mark to reject rollbacks; at the next check-in the server re-anchors real time and re-issues the lease. An honest user who is offline for a week keeps working the whole week — their clock is fine, the high-water mark climbs normally, the lease is still within its window. Only a rolled-back clock trips the check. (For the offline side of this contract, see offline license validation; for the key that carries the window, air-gapped activation.)
Don't punish the honest user
The failure mode to avoid is treating every clock anomaly as fraud. Real clocks are messy: laptops resume from sleep with a stale clock, VMs restore from snapshots, batteries die and reset the RTC to 1970, and travelers cross time zones (though UTC makes that a non-issue if you compare in UTC). If you hard-lock on the first backwards step, you will lock out paying customers whose clock hiccuped.
Design the response to be proportional. A clock earlier than the high-water mark is not proof of malice — so don't deactivate; just refuse to trust the clock and fall back to the high-water mark, which is already the safe behaviour. Reserve hard action for the case that actually matters: the lease window has genuinely elapsed and the app cannot reach the server to renew. Even then, prefer a grace reminder and a path to re-validate over an abrupt lockout. The attacker who wound the clock back sees no benefit; the honest user whose clock glitched sees nothing at all.
The short version
Local time is input from an untrusted source, so never let DateTime.UtcNow alone decide expiry. Keep a forward-only high-water mark so a rolled-back clock cannot un-expire anything offline, take authoritative time from the server on every check-in, and make your tamper response proportional so legitimate clock noise never costs a real user their access. Expiry stops being a date the user can edit and becomes a fact your system actually controls.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.