docusealco/docuseal · error · WebhookUrls::Signatures::TimestampError
In future
Error message
In future
What it means
WebhookUrls::Signatures.verify (lib/webhook_urls/signatures.rb:22) parses the header as '<unix_timestamp>.<hmac>' and enforces a freshness window: the timestamp must be within `tolerance` seconds (default TOLERANCE = 300) of the receiver's clock. Line 31 raises TimestampError('In future') when ts > now + tolerance, i.e. the signature's timestamp is more than 5 minutes ahead of the verifying server. This blocks fabricated or replayed signatures that claim to be issued in the future, which usually indicates clock skew between signer and verifier rather than an attack.
Source
Thrown at lib/webhook_urls/signatures.rb:31
def generate_secret
SECRET_PREFIX + Base64.strict_encode64(SecureRandom.bytes(SECRET_BYTES))
end
def sign(secret, body:, timestamp: Time.current.to_i)
"#{timestamp}.#{OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{body}")}"
end
def verify(secret, body:, header:, tolerance: TOLERANCE)
ts, sig = header.to_s.split('.', 2)
ts = Integer(ts, exception: false)
raise InvalidSignatureError unless ts && sig
now = Time.current.to_i
raise TimestampError, 'Too old' if ts < now - tolerance
raise TimestampError, 'In future' if ts > now + tolerance
expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{ts}.#{body}")
raise InvalidSignatureError unless ActiveSupport::SecurityUtils.secure_compare(expected, sig)
true
end
end
end
View on GitHub (pinned to 004a22c1c8)
Solutions
- Compare clocks on both hosts (date -u on each, or log ts - Time.current.to_i when the error fires) and fix drift by enabling NTP/chrony/systemd-timesyncd on the machine whose clock is off.
- If the sender uses milliseconds, switch to unix seconds: use Time.current.to_i (Ruby) or Math.floor(Date.now() / 1000) (JS) as the timestamp passed to WebhookUrls::Signatures.sign.
- In tests, do not reuse recorded headers; regenerate the header inside the test with WebhookUrls::Signatures.sign(secret, body: body) while the clock is frozen, so signer and verifier share the same 'now'.
- As a temporary measure while clock sync is being fixed, pass a larger window explicitly: WebhookUrls::Signatures.verify(secret, body:, header:, tolerance: 15 * 60) — shrink it back to 300 once skew is corrected, since a wide window weakens replay protection.
Example fix
# before (sender signs with a millisecond epoch) header = WebhookUrls::Signatures.sign(secret, body: payload, timestamp: Time.current.to_i * 1000) # -> verifier raises TimestampError 'In future' (ts is centuries ahead) # after (unix seconds on both sides) header = WebhookUrls::Signatures.sign(secret, body: payload, timestamp: Time.current.to_i)
Defensive patterns
Strategy: try-catch
Validate before calling
# Pre-check the timestamp window before calling verify, so a skewed clock
# is reported as data, not as an exception:
ts, _sig = request.headers['Webhook-Signature'].to_s.split('.', 2)
ts = Integer(ts, exception: false)
if ts && ts > Time.current.to_i + WebhookUrls::Signatures::TOLERANCE
Rails.logger.warn("webhook clock skew: sender is #{ts - Time.current.to_i}s ahead")
return head :bad_request
end Type guard
def webhook_timestamp_plausible?(header, tolerance: WebhookUrls::Signatures::TOLERANCE)
ts, _sig = header.to_s.split('.', 2)
ts = Integer(ts, exception: false)
!!ts && ts.between?(Time.current.to_i - tolerance, Time.current.to_i + tolerance)
end Try / catch
begin
WebhookUrls::Signatures.verify(secret, body: request.raw_post, header: request.headers['Webhook-Signature'])
rescue WebhookUrls::Signatures::TimestampError => e
# 'Too old' or 'In future': clock skew or replay. Respond 400 and log the
# delta (parsed ts vs now). Do NOT widen tolerance here or auto-retry —
# fix clock sync instead. Note: TimestampError is NOT a superclass of
# InvalidSignatureError; rescue them as separate clauses.
Rails.logger.warn("webhook timestamp rejected: #{e.message}")
head :bad_request
end Prevention
- Run NTP/chrony/systemd-timesyncd on every host that signs or verifies webhooks, and alert when sender-vs-receiver drift exceeds 60s.
- Standardize on unix seconds for the signature timestamp across all senders (never milliseconds from Date.now()/Time#to_i * 1000).
- In test suites, generate the signature inside the test with WebhookUrls::Signatures.sign under the same frozen clock instead of replaying recorded header fixtures.
- Log the timestamp delta (ts - Time.current.to_i) on every verification; drift approaching the 300s tolerance is an early warning before failures start.
When it happens
Trigger: Calling WebhookUrls::Signatures.verify(secret, body:, header:) with a header whose timestamp component satisfies ts > Time.current.to_i + tolerance. Concretely: the sender machine's clock runs more than 5 minutes ahead of the receiver; sign was called with a millisecond epoch (13-digit ts, e.g. (Time.current.to_i * 1000) or JavaScript Date.now()) so ts is ~year 50000; a custom timestamp: passed to Signatures.sign that is in the future; or in tests, a header fixture generated earlier being verified against a frozen/traveled clock (Timecop) that is behind real time.
Common situations: NTP not running or drifted on Docker/Kubernetes nodes, VMs after host suspend, or bare-metal servers, so signer and verifier clocks diverge; porting sender code from JavaScript where Date.now() yields milliseconds; replaying recorded webhook fixtures in an automated test suite without re-signing them against the test clock; staging and production servers in different time sources; explicitly passing a future timestamp when generating test signatures.
Related errors
- Too old
- InvalidSignatureError
- Invalid Timeserver
- Failed to create new document
- Failed to load document from file '#{file_path}', pointer is
AI-assisted analysis of docusealco/docuseal@004a22c1c8 (2026-08-21).
Data as JSON: /api/errors/b9532c1b7931ccc6.
Report an issue: GitHub.