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
- 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.
- If rotated/lost, redirect the user through the OAuth authorization flow again to obtain a fresh token pair.
- Verify you are calling the same Canvas host/account that issued the token and that the user hasn't revoked the token.
- 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
- For public (PKCE) clients, always overwrite the stored refresh token with the one from each refresh response
- Handle invalid_refresh_token by re-running the authorization flow, not by retrying
- Store tokens losslessly (no trimming/re-encoding) and on the correct environment
- Detect user revocation and prompt re-auth rather than looping on refresh
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
- refresh_token_not_supplied
- assertion method not supported for this grant_type
- invalid_client_id
- invalid_redirect
- @provider.error_message
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)