instructure/canvas-lms · error · CanvasSecurity::ServicesJwt::InvalidRefresh

refresh window exceeded

Error message

refresh window exceeded

What it means

CanvasSecurity::ServicesJwt raises InvalidRefresh with this message when the token being refreshed was issued too long ago: past_refresh_window?(payload[:exp]) determines the refresh is outside the allowed grace period beyond the token's expiration. Services tokens can only be refreshed within a short window after expiring; older ones must be fully re-issued.

Solutions

  1. Stop refreshing and request a brand-new token via ServicesJwt.for_user(user, domain: ...) with a fresh authentication/session.
  2. Check REFRESH_WINDOW / token TTL configuration (CanvasSecurity services jwt settings) if legitimate refreshes fail quickly — clocks or env config may be off.
  3. Reduce retry backoff so refresh attempts happen before the window closes, or add server-side clock-skew tolerance.
  4. Confirm server times are NTP-synchronized; large skew can push exp outside the window immediately.
  5. If this fires consistently right after expiry, increase the refresh window setting for that environment.

Example fix

// before
new_token = ServicesJwt.refresh(old_token, domain: domain, user: user) rescue nil
// after
if ServicesJwt.new(old_token).expired? && past_window?
  new_token = ServicesJwt.for_user(user, domain: domain).token
else
  new_token = ServicesJwt.refresh(old_token, domain: domain, user: user)
end
Defensive patterns

Strategy: try-catch

Validate before calling

payload = CanvasSecurity::ServicesJwt.new(jwt).original_token(ignore_expiration: true)
refreshable = payload[:exp] && (payload[:exp] - Time.zone.now.to_i) < CanvasSecurity::ServicesJwt::REFRESH_WINDOW

Type guard

def within_refresh_window?(payload)
  exp = payload.is_a?(Hash) && payload[:exp]
  exp && (Time.zone.now.to_i - exp) <= CanvasSecurity::ServicesJwt::REFRESH_WINDOW
end

Try / catch

begin
  new_token = CanvasSecurity::ServicesJwt.refresh(jwt, domain: domain, user: user)
rescue CanvasSecurity::ServicesJwt::InvalidRefresh
  new_token = CanvasSecurity::ServicesJwt.for_user(user, domain: domain).token
end

Prevention

When it happens

Trigger: Calling refresh on a services JWT whose exp is further past (or the remaining refresh allowance is smaller) than the configured refresh window (e.g. token expired hours ago, client kept retrying refresh in a loop).

Common situations: Long-lived browser tabs resuming after token expiry; background jobs replaying a cached expired token; clock skew between services making the token appear older; refresh endpoint retries after network failures eventually exceeding the window.

Related errors


AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15). Data as JSON: /api/errors/92614f9b5dc1649b. Report an issue: GitHub.

Appendix: source

Thrown at gems/canvas_security/lib/canvas_security/services_jwt.rb:144

    if root_account_uuid
      payload[:root_account_uuid] = root_account_uuid
    end
    generate(payload, base64:, symmetric:, encrypt:)
  end

  def self.refresh_for_user(jwt, domain, user, real_user: nil, symmetric: false)
    begin
      payload = new(jwt, wrapped: false).original_token(ignore_expiration: true)
    rescue JSON::JWT::InvalidFormat
      raise InvalidRefresh, "invalid token"
    end

    if refresh_invalid_for_user?(payload, domain, user, real_user)
      raise InvalidRefresh, "token does not match user and domain"
    end

    if past_refresh_window?(payload[:exp])
      raise InvalidRefresh, "refresh window exceeded"
    end

    if payload[:context_type].present?
      context = payload[:context_type].constantize.find(payload[:context_id])
    end

    for_user(domain,
             user,
             real_user:,
             workflows: payload[:workflows],
             context:,
             symmetric:)
  end

  def self.create_payload(payload_data)
    if payload_data[:sub].nil?
      raise ArgumentError, "Cannot generate a services JWT without a 'sub' entry"
    end

View on GitHub (pinned to 1c9f0bb801)