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
- 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).
- Regenerate the token from scratch with ServicesJwt.for_user(correct_user, domain: correct_domain) instead of refreshing a mismatched one.
- Verify the domain string matches the token's aud/hostname entry, including subdomain and port, e.g. canvas.dev.local vs canvas.test.local.
- If impersonating, confirm real_user is set the same way at creation (as_user/real_user fields) as at refresh.
- 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
- Always refresh with the exact user/domain pair used at token creation
- Store domain alongside the cached token
- Handle masquerade by persisting real_user with the token
- Reissue instead of refreshing when in doubt
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
- Attachment verifier token id mismatch. token id: #
- refresh window exceeded
- Attachment verifier token expired: #
- Attachment verifier token invalid: #
- Cannot generate a services JWT without a 'sub' entry
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)