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

token does not match user and domain

Error message

token does not match user and domain

What it means

CanvasSecurity::ServicesJwt raises InvalidRefresh with this message when refreshing a services JWT: the decoded token's subject/context does not match the user (and masqueraded real_user) or the request domain supplied to the refresh call. It guards against using a token issued for a different user or host to mint a new one, preventing token confusion and cross-domain replay.

Solutions

  1. Ensure the user, real_user, and domain arguments passed to refresh are exactly those the token was originally created with (check payload sub/real_user_id/domainhostname).
  2. Regenerate the token from scratch with ServicesJwt.for_user(correct_user, domain: correct_domain) instead of refreshing a mismatched one.
  3. Verify the domain string matches the token's aud/hostname entry, including subdomain and port, e.g. canvas.dev.local vs canvas.test.local.
  4. If impersonating, confirm real_user is set the same way at creation (as_user/real_user fields) as at refresh.
  5. Inspect the payload (ServicesJwt.new(token).original_token(ignore_expiration: true)) to compare sub, real_user_id, and aud against your arguments.

Example fix

// before
ServicesJwt.refresh(stale_token, domain: "canvas.example.com", user: other_user)
// after
jwt = CanvasSecurity::ServicesJwt.for_user(original_user, domain: original_domain)
new_token = CanvasSecurity::ServicesJwt.refresh(token, domain: original_domain, user: original_user)
Defensive patterns

Strategy: try-catch

Validate before calling

token = CanvasSecurity::ServicesJwt.new(jwt).original_token(ignore_expiration: true)
raise TokenMismatch unless token[:sub] == user.global_id.to_s
correct = (token[:aud] || []).include?(domain) || token[:domainhostname] == domain

Type guard

def valid_services_token?(payload, user, domain)
  payload.is_a?(Hash) && payload[:sub] == user.global_id.to_s &&
    (payload[:domainhostname] == domain || Array(payload[:aud]).include?(domain))
end

Try / catch

begin
  new_token = CanvasSecurity::ServicesJwt.refresh(jwt, domain: domain, user: user, real_user: real_user)
rescue CanvasSecurity::ServicesJwt::InvalidRefresh => e
  Rails.logger.warn("token refresh rejected: #{e.message}; reissuing")
  new_token = CanvasSecurity::ServicesJwt.for_user(user, domain: domain).token
end

Prevention

When it happens

Trigger: Calling ServicesJwt.for_user(user, domain: ...).refresh(token, ...) (or the module-level refresh wrapper) with a JWT whose :sub is not the given user's global_id, whose :real_user does not match real_user, or whose :aud/:domainhostname does not match the domain argument.

Common situations: Passing a token issued for an admin while refreshing as the impersonated (real) user; swapping tokens between dev/test hosts where the domain hostname differs; caching a token per session but resolving the current user from a different shard or login; copying a token into a service that calls back with its own domain.

Related errors


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

Appendix: source

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

    end
    if audience
      payload[:aud] = audience
    end
    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

View on GitHub (pinned to 1c9f0bb801)