Time sync
Why tokens need a correct clock, how much skew the relay tolerates, what fails when a clock is wrong, and how to keep time on devices.
Tokens carry times, and every check against them uses a clock. A token has
an issue time (iat) and an expiry (exp). The tokens we mint carry
those two and no not-before (nbf) claim. The relay compares them with its own
clock, and refuses everything while that clock is not trustworthy.
Whose clock matters
| Clock | What it decides | If it is wrong |
|---|---|---|
| The API host (it mints) | iat and exp of every token | no token is minted while the kernel says the clock is unsynchronized: 503 clock_unsynchronized |
| The relay host (it checks) | whether a token is expired or not yet issued | every token is refused while the kernel says the clock is unsynchronized; a clock that is behind by more than the skew refuses tokens as "issued in the future" |
| Your backend | nothing: it calls the API and passes the answer on | nothing |
| A browser | when the player renews (timed from the token's own iat and exp, not the page's clock) | see below |
| A device that publishes with a token from your backend | nothing in the token | its TLS connection to the relay can fail if the clock is far out (a certificate is valid between two dates) |
A host that mints its own tokens (nearglass token mint, a self-hosted relay) | iat and exp of its tokens | see the table of runs below |
With the managed service the two clocks that matter are ours. If you publish or watch with tokens from our API, a wrong clock on a device or a browser does not make a token invalid. It matters for self-hosted relays and for any machine of yours that mints tokens.
Two things in the player depend on the page's clock:
- A descriptor looks stale to the player when its expiry is less than 10 s away on the page's clock. A browser whose clock is ahead by more than the token's life minus 10 s (110 s for the default 120 s) treats every token it gets as stale, so it asks for a second token before each connect. That costs tokens against the limit of 6 a minute for a seat, not a failure.
- Renewal itself does not: it is timed from
expminusiat, counted from when the token arrived (Token renewal).
What the relay tolerates
The relay's check, for each token, at setup and again on each request:
| Check | Rule | Refused as |
|---|---|---|
| Expired | the token is good until exp plus the skew | an expired token |
| Issued in the future | refused when iat is later than the relay's now plus the skew | unauthorized; the relay logs why |
| Too long | refused when exp minus iat is more than 7 days | unauthorized |
No iat | bounded by exp alone | accepted by the other rules |
| Clock unsynchronized | no token is accepted, at setup or on any request | clock not synchronized |
The skew is the relay scope's clock_skew_secs. For every customer scope in
the managed service it is 2 s. The sample scope file for a self-hosted
relay (relay/scope.example.json) sets 60. A longer skew tolerates a worse
clock and lets an expired token live that much longer; a publishing session
(a driver's turn too) ends at exp plus the skew.
The reason for the issue-time rule: a relay whose clock is behind would accept tokens that have expired. A token issued after that relay's now means the relay's clock is behind its issuer's, so it refuses and logs it:
ERROR moq_relay_ietf::clock_sync: refusing a token issued in the future: this relay's
clock is behind its issuer's by more than the clock skew, so it would also accept
expired tokens; fix the time sync subject="device:skew" issued_at=1793560367 now=1793560362 behind_secs=5The relay writes it at most every 10 s and counts every refusal in
moq_relay_token_issued_in_future_total.
A real run
A test stack (relay and API as packaged, skew 2 s) and a publisher using
tokens minted with a clock offset. nearglass token mint always stamps the
current time, so these came from our test script (demo/sales/cat.mjs,
which takes a now), signed with the tenant's key:
| The minting clock | Token | What the relay did |
|---|---|---|
| 1 s ahead | ttl 60 | accepted, published |
| 5 s ahead | ttl 60 | refused, unauthorized, with the log line above (behind_secs=5) |
| 3600 s ahead | ttl 60 | refused, unauthorized |
| 30 s behind | ttl 60 | accepted (it lives 30 s of real time) |
| 30 s behind | ttl 20 | refused: already expired when minted |
| 3600 s behind | ttl 7200 | accepted (it lives 3600 s of real time) |
A clock that is behind makes tokens shorter in real time, so they fail closed. A clock that is ahead by more than the skew is refused.
Also run: a token with ttl 1, used 5 s later, was refused the same way
(the relay closes with code 2, unauthorized); a connect with no token
the same.
The refused publisher in these runs, nearglass publish, did not back off: it
reconnected hundreds of times a second for as long as it ran. A device that
is refused should wait before it asks again.
What "synchronized" means
On Linux the relay and the API ask the kernel (adjtimex, read only, no
privilege needed): the clock is synchronized when the kernel's time state is
not an error and its STA_UNSYNC flag is clear. That is what timedatectl
shows as System clock synchronized: yes. A time service (chrony, ntpd,
systemd-timesyncd) clears the flag once it has set the clock, and the kernel
sets it again when the service has not updated the clock for a long time.
Anything other than Linux, or a failed call, counts as not synchronized.
Check a host:
timedatectl # System clock synchronized: yes
chronyc tracking # with chronyA relay started before its time service has synced logs system clock is not synchronized: token scopes refuse every token until it is and starts
serving by itself once the clock syncs. The relay's gauge
moq_relay_clock_synchronized is 1 or 0, and
moq_relay_clock_unsynced_refusals_total counts what it refused.
Keep time on devices and hosts
- Every relay and API host: run chrony or systemd-timesyncd, and alert on
timedatectlsayingno, or on the gauge above being 0. - Devices that mint nothing and take tokens from your backend do not need tight time for the tokens. Give them a time service anyway: certificate checks, logs and any frame timestamps you compare need it.
- Boards without a battery-backed clock start at a wrong time (often the
date they were built). Start the time service at boot and wait for it
before connecting to the relay (
timedatectlshowsyes, orchronyc waitsync). - A device that mints its own tokens, or a self-hosted relay on its own network: give it an NTP source you control, and keep the skew small (a few seconds) so a turn ends close to its token.