Skip to content

Clock skew and replay windows

OIDC artifacts do not all have the same freshness or replay shape: authorization codes and PAR records are consumed server-side, while JWTs and proofs carry time claims. A server whose clock has drifted can accept stale artifacts, and a server without replay state can accept the same one twice. The OP therefore combines explicit TTLs, bounded clock tolerance, one-time state, and compare-and-set replay stores.

This page collects the security-relevant defaults that are easy to misread, the constant or option that controls each one, and the page where the surrounding feature is explained. It also records the reauthentication, passkey, and JWKS limits that are not represented by an iat/exp row.

Clock source

Every TTL the OP enforces is computed against op.Clock.Now(), the wall-clock interface defined in op/clock.go:

go
type Clock interface {
    Now() time.Time
}

Production deployments leave the field unset and the library installs a system-clock implementation. Test suites and deterministic replay harnesses pass a fake Clock so token TTLs, audit timestamps, and rate-limit windows all advance under their control. Freshness decisions in the core use the clock seam; an adapter that cannot import the seam documents its own fallback and can expose an override. Keep the clock boundary singular in an embedder rather than adding ad-hoc time.Now() checks around protocol decisions.

The implication for embedders: NTP / chronyd is mandatory. The OP trusts its own wall clock to compare iat and exp against the current instant. A machine whose clock has drifted by minutes will silently accept proofs that the issuing client believes are stale, or reject proofs that the issuing client just signed. Run an OP only on hosts with disciplined time sync.

Window summary table

ArtifactDefenseDefault windowVerify path
Authorization codeone-time consumption + TTLDefaultAuthCodeTTL = 60 s/concepts/authorization-code-pkce
Refresh token (rotation grace)acceptance of the just-rotated previous tokenrefresh.GraceTTLDefault = 60 s (configurable via op.WithRefreshGracePeriod)/concepts/refresh-tokens
DPoP proof — iat windowsymmetric tolerance around server time60 s/concepts/dpop
DPoP proof — jti cachededup against replayapproximately 120 s/concepts/dpop
JAR (RFC 9101) request object — jti cachededup against replayserver-bounded expiry: max(exp, now + max-age), capped at now + max(max-age, maxLifetime), then clock skew/security/design-judgments#dj-6
JAR — future skewnbf / iat tolerancejar.DefaultMaxFutureSkew = 60 s/security/design-judgments#dj-6
PAR (RFC 9126) request_uri lifetimeone-time, short-livedparendpoint.DefaultTTL = 60 s/concepts/fapi
Back-Channel Logout tokenOP signs once, RP dedupsOP does not enforce a jti cache; RP-side responsibility/use-cases/back-channel-logout
ID Tokeniat + expexp - iat = defaultIDTokenTTL = 10 min (fixed, independent of the request's access-token TTL)/concepts/tokens
Access token (JWT or opaque)iat + expDefaultAccessTokenTTL = 5 min, capped at AccessTokenTTLMax = 24 h (FAPI profile caps at 10 min)/concepts/tokens
Refresh token (absolute lifetime)exp from issuancetimex.RefreshTokenTTLDefault = 30 days/concepts/refresh-tokens
client_assertion (private_key_jwt)iat + exp + jti dedup, ±60 s leewayjti marked with expiresAt = assertion.exp + leeway (60 s), capped at now + maxAssertionLifetime + leeway/concepts/client-types

The constants in the "Default window" column are the source of truth for the current core. For JAR, the request object's exp does not control retention by itself: the verifier's maximum lifetime ceiling (when configured) and its max-age floor bound the marker, with clock skew added. The table intentionally does not turn every application-configurable TTL into a promise: With* options and active profiles can narrow or replace many defaults. Recheck the linked source and tests when upgrading the core.

The same selected windows, drawn to scale on a logarithmic time axis, show the broad shape: replay and clock windows cluster around a minute, token lifetimes step up to minutes, and the refresh token's absolute lifetime reaches 30 days.

Every clock and replay window, to scale
A logarithmic comparison of every clock and replay window the library enforces, from the sixty-second replay band up to the thirty-day absolute refresh-token lifetime. Filled circles are clock and TTL freshness checks; hollow squares are store-backed replay deduplication.window length after issuance · logarithmic axisclock / TTL freshnessstore-backed replay dedupAuthorization code60 sPAR request_uri60 sRefresh token — grace60 sDPoP proof — iat±60 sJAR — future skew60 sclient_assertion±60 sDPoP proof — jti cache≈120 sAccess token5 min · FAPI 10 minID token10 minRefresh token — absolute30 days1 min10 min1 h1 day30 days
Six of the ten sit on the leftmost gridline. That clustering is the design: anything an attacker could replay is measured in seconds, and only the things a user would notice losing are measured in days.

Reauthentication freshness

prompt=login and a violated max_age are freshness requirements, not display hints. At /authorize entry, prompt=login sets the reauthentication flag unconditionally. max_age compares the request's non-negative integer seconds with the session's recorded auth_time, and max_age=0 always requires a new factor. The comparison stays in integer-second arithmetic instead of converting an untrusted value to time.Duration, so very large values cannot wrap and reverse the freshness decision. The existing browser session or consent grant cannot satisfy a violated freshness requirement. The interaction state carries the decision to the authenticator orchestrator, whose terminal guard rejects a factor-less result with ErrReauthNotPerformed; a completed factor stamps a new auth_time on the resulting session and grant.

JWT jti and clock tolerance

The in-library JWT access-token verifier applies tokens.DefaultLeeway = 30 s symmetrically to exp and iat when no verifier-specific leeway is configured. RequireJTI is enabled for the OP's non-None access-token revocation strategies, because the registry or deny-list cannot answer a revocation query without a token identifier; RevocationStrategyNone does not require it. This is separate from DPoP/JAR/client-assertion replay retention: there is no universal 30-second jti cache for every JWT use.

Passkey challenge retirement

Each passkey registration or login attempt mints a fresh WebAuthn challenge and receives a Session with a default five-minute absolute lifetime. If an assertion fails, the authenticator orchestrator clears the in-flight factor scratch and increments the signed StateRef counter before persisting the failed state. The old StateRef and its challenge are therefore retired together; a rejected assertion cannot be replayed during the broader ten-minute interaction-state lifetime. A successful assertion follows the normal credential update and clone-warning rules.

Remote JWKS bounds

Relying-party JWKS fetched from a registered jwks_uri is bounded on five axes:

BoundValue
total fetch timeout5 s
maximum positive-cache TTL5 min
response body64 KiB
declared keys64
distinct URL loads in flight, process-wide64

Unknown-kid forced refreshes are throttled to once per URL per 20 seconds. Cache cardinality is capped at 256 positive/negative URL entries, and a negative fetch result is cached for five seconds. These are core safety limits, not a guarantee that a deployment's upstream JWKS will remain available; allow a key rotation strategy that works within them.

What "replay" means here

"Replay" is one word in the spec and three different defenses in the code.

Token replay — the same bearer-shaped credential is presented twice. Authorization codes are consumed on first use (the row is marked spent in AuthorizationCodeStore). Refresh tokens rotate on every redemption and reuse-detection cascades a revoke when the previous token is presented after the grace window closes. DPoP proofs are deduped by jti: the nonce gate runs first, then the accepted proof is committed to ConsumedJTIStore, so a second proof carrying the same jti returns ErrProofReplayed once the first proof has passed the gate.

Request replay — the same authorize request is submitted again with the same parameters. PAR makes this structurally impossible by issuing a one-time request_uri: once /authorize consumes the URN it is gone from PushedAuthRequestStore. JAR (request objects passed inline as request=) defends with jti dedup against the same ConsumedJTIStore substore. Markers use jar:<clientID>:<jti> keys and follow the server-bounded expiry calculation: the request's exp is subject to the verifier's max-age floor and maximum-lifetime ceiling, plus clock skew.

Logout replay — the same logout_token is POSTed to the RP a second time. Back-Channel Logout 1.0 §2.6 places the dedup responsibility on the RP, not the OP: the OP signs and delivers once, and each RP is expected to track recently-seen logout-token jti values for its own purposes. The OP does not maintain a logout-token replay cache because the relevant boundary is the RP.

Read these three together as the model: the artifact's use defines the defense. Codes are spent, refresh tokens rotate, DPoP / JAR / client_assertion carry an explicit jti and rely on a shared dedup table, and logout tokens delegate dedup to the receiver.

ConsumedJTIStore (declared in op/store/jti.go) is the contract every jti-based defense routes through. DPoP, JAR, and client_assertion all call Mark(ctx, key, expiresAt) on the same substore; the namespacing of the key (jar:<clientID>:<jti>, the bare jti for DPoP and assertions) keeps them from colliding.

For multi-instance deployments, the substore must be shared across instances or every instance must be sticky-routed for the lifetime of a given jti. Backing it with the in-memory implementation across multiple replicas defeats the defense — replica A and replica B will each accept the same proof exactly once. A fast shared cache (Redis, Memcached) is the typical choice; the deployment guidance in /operations/multi-instance discusses the volatile / durable split that this implies.

ConsumedJTIStore is explicitly outside the transactional cluster (see op/store/tx.go). The operations are idempotent ("first writer wins; subsequent writers see already-consumed"). A total cache flush reopens replay only until each artifact's own acceptance boundary. The DPoP marker is retained through the proof's allowable iat range. JAR markers retain through the server-bounded exp / max-age / maximum-lifetime calculation (plus skew), and client assertions use their own bounded expiry. Token lifetimes and browser interaction state are separate budgets and are intentionally longer.

The other deployment knob is the wall clock itself. Every replica runs the same OP code with the same defaults; if their clocks disagree by more than the relevant window, the replicas will disagree about whether a given artifact is fresh. NTP discipline across the whole fleet is part of the security posture, not a "nice to have".

Edge cases

Daylight saving and leap seconds are not relevant. Every protocol timestamp the OP compares is a Unix epoch second. DST transitions and leap-second adjustments do not move the epoch. The constants above are pure time.Duration values, so a 60-second grace stays 60 seconds across a local-time transition.

Long-lived tokens versus short windows. Refresh tokens default to 30 days; the rotation grace is 60 seconds. This pairing is deliberate: the long lifetime supports background-sync clients across reasonable network outages, while the short grace bounds how long a leaked previous token remains usable after the legitimate client has rotated. The trade-off is documented in /security/design-judgments — the library prefers correctness (cascade-revoke on reuse) over availability (silently accept replays) under network blips, and the 60-second window is the smallest interval that covers the typical mobile retry without leaving the previous token usable for long.

DPoP nonce vs DPoP iat. The iat claim is signed by the client, so a compromised client can pre-stage proofs valid for the full 60-second window. DPoP nonces (RFC 9449 §8) close that gap by adding a server-controlled freshness signal that the client must echo. Both are validated independently — a proof that passes the iat window but fails the nonce check is rejected, and a proof that carries a fresh nonce but a stale iat is also rejected. See /concepts/dpop and /use-cases/dpop-nonce for the detailed flow.

Stale-nonce ordering and jti consumption. The DPoP verifier marks jti after the nonce gate passes, not before. A proof that fails the nonce check never advances the ConsumedJTIStore, so a client retrying with a fresh nonce can resubmit the same jti once. A proof that passes the nonce gate and is then resubmitted with a fresher nonce returns ErrProofReplayed because the jti is already marked. The order is documented inline in internal/dpop/verify.go.