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

  1. 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.
  2. 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.
  3. 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'.
  4. 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

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


AI-assisted analysis of docusealco/docuseal@004a22c1c8 (2026-08-21). Data as JSON: /api/errors/b9532c1b7931ccc6. Report an issue: GitHub.