instructure/canvas-lms · error · Canvas::OAuth::RequestError

invalid_refresh_token

invalid_refresh_token

Error message

invalid_refresh_token

What it means

Canvas's OAuth2 refresh_token grant raises Canvas::OAuth::RequestError with code invalid_refresh_token when @provider.token_for_refresh_token cannot find an AccessToken matching the supplied refresh_token. The refresh token is unknown, expired, already rotated (public clients rotate on every use), or revoked.

Solutions

  1. Use the latest refresh_token from the most recent token response — for public clients it rotates on every refresh, so always overwrite the stored value.
  2. If rotated/lost, redirect the user through the OAuth authorization flow again to obtain a fresh token pair.
  3. Verify you are calling the same Canvas host/account that issued the token and that the user hasn't revoked the token.
  4. Check storage/transport of the token for encoding damage (don't trim, correctly URL-encode form bodies).

Example fix

// before
refreshWith(oldRefreshToken) // rotated token, now invalid
// after
const resp = await refreshWith(current.refresh_token)
current = { access_token: resp.access_token, refresh_token: resp.refresh_token }
Defensive patterns

Strategy: try-catch

Validate before calling

unless stored_refresh_token && stored_refresh_token == latest_token_response.refresh_token
  reauthorize
end

Type guard

def plausible_refresh_token?(t)
  t.is_a?(String) && t.length > 20
end

Try / catch

begin
  token = refresh_access_token(stored_refresh_token)
rescue Canvas::OAuth::RequestError => e
  if e.message.to_s == 'invalid_refresh_token'
    clear_stored_tokens
    redirect_to_oauth_authorization
  end
end

Prevention

When it happens

Trigger: POST to /login/oauth2/token with grant_type=refresh_token and a refresh_token value that matches no token: token was regenerated by a previous refresh on a public (PKCE) client, the token expired or was deleted, the value was truncated/URL-decoded incorrectly, or it belongs to a different Canvas environment.

Common situations: Public-client apps reusing the old refresh token after Canvas rotated it (generate_refresh_token overwrite: true in generate_token); replaying a refresh token after user token revocation; environment mismatch (dev token used against prod); storing the token with lossy encoding (plus signs, trimming).

Related errors


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

Appendix: source

Thrown at lib/canvas/oauth/grant_types/refresh_token.rb:22

  module GrantTypes
    class RefreshToken < BaseType
      def supported_type?
        true
      end

      # Access tokens obtained by public clients through PKCE should
      # be refreshed using this grant type
      def allow_public_client?
        true
      end

      private

      def validate_type
        raise Canvas::OAuth::RequestError, :refresh_token_not_supplied unless @opts[:refresh_token]

        @_token = @provider.token_for_refresh_token(@opts[:refresh_token])
        raise Canvas::OAuth::RequestError, :invalid_refresh_token unless @_token
        raise Canvas::OAuth::RequestError, :incorrect_client unless @_token.access_token.developer_key_id == @_token.key.id
      end

      def generate_token
        @_token.access_token.regenerate_access_token

        if provider.key.public_client?
          # Access tokens for public clients have a (default) two-hour rolling window
          # in which tokens are eligible for refresh. When a refresh action is take for
          # a public client, extend that window by another two hours.
          @_token.access_token.set_permanent_expiration

          # For better token security, force public clients to rotate refresh tokens
          # after each use. This helps mitigate the risk of a leaked refresh token.
          @_token.access_token.generate_refresh_token(overwrite: true)
          @_token.access_token.save
        end

View on GitHub (pinned to 1c9f0bb801)