instructure/canvas-lms · error · InvalidTokenError
token has expired
Error message
token has expired
What it means
Lti::OAuth2::AccessToken.from_jwt decodes a Canvas-issued LTI OAuth2 JWT with Canvas::Security.decode_jwt, which raises Canvas::Security::TokenExpired if the exp claim has passed. from_jwt rescues that and raises InvalidTokenError with the message 'token has expired'.
Solutions
- Mint a fresh token via Lti::OAuth2::AccessToken.create_jwt (or from_jwt on a new JWT) and retry
- Process launch/service payloads promptly instead of storing tokens for later use
- If expiry seems premature, synchronize clocks and verify the exp value embedded at issuance
Example fix
# before token = stored_launch_token Lti::OAuth2::AccessToken.from_jwt(aud: aud, jwt: token).validate! # after raise Lti::OAuth2::AccessToken::InvalidTokenError, 'stored token too old' if token_expired?(stored_launch_token) token = Lti::OAuth2::AccessToken.create_jwt(aud: aud, sub: sub).to_s
Defensive patterns
Strategy: try-catch
Validate before calling
exp = JSON.parse(Base64.urlsafe_decode64(jwt.split('.')[1]))['exp']
raise 'token expired' if exp && exp <= Time.now.to_i Try / catch
begin
token = Lti::OAuth2::AccessToken.from_jwt(aud: aud, jwt: jwt)
rescue Lti::OAuth2::AccessToken::InvalidTokenError => e
Rails.logger.warn("invalid LTI token: #{e.message}")
jwt = Lti::OAuth2::AccessToken.create_jwt(aud: aud, sub: sub).to_s
retry
end Prevention
- Consume launch/service tokens promptly; don't store them beyond exp
- Use a short cache TTL and re-mint via create_jwt when stale
- Synchronize clocks between issuing and validating hosts
- Validate fixtures in tests have future exp values
When it happens
Trigger: Calling Lti::OAuth2::AccessToken.from_jwt with a JWT whose exp is in the past - typically a service-JWT produced by AccessToken.create_jwt and consumed later than 1 hour after issuance.
Common situations: Message/auth tokens from LTI launches stored and replayed later, queued jobs processing stale launch data, clock skew making tokens appear expired, or test fixtures with hardcoded past exp values.
Related errors
- Access token expired
- Access token invalid - signature likely incorrect
- either the tool proxy or developer key were not found
- iat must be in the past
- Invalid access token field/s: #
AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15).
Data as JSON: /api/errors/c0d3d91ded88f686.
Report an issue: GitHub.
Appendix: source
Thrown at lib/lti/oauth2/access_token.rb:37
module Lti
module OAuth2
class AccessToken
private_class_method :new
ISS = "Canvas"
attr_reader :aud, :sub, :reg_key, :shard_id
def self.create_jwt(aud:, sub:, reg_key: nil)
new(aud:, sub:, reg_key:, shard_id: Shard.current.id)
end
def self.from_jwt(aud:, jwt:)
decoded_jwt = Canvas::Security.decode_jwt(jwt)
new(aud:, sub: decoded_jwt[:sub], jwt:, shard_id: decoded_jwt[:shard_id])
rescue Canvas::Security::TokenExpired => e
raise InvalidTokenError, "token has expired", e.backtrace
rescue => e
raise InvalidTokenError, e
end
def initialize(aud:, sub:, jwt: nil, reg_key: nil, shard_id: nil)
@_jwt = jwt if jwt
@reg_key = reg_key || (jwt && decoded_jwt["reg_key"])
@aud = aud
@sub = sub
@shard_id = shard_id
end
def validate!
decoded_jwt = Canvas::Security.decode_jwt(jwt)
check_required_assertions(decoded_jwt.keys)
raise InvalidTokenError, "invalid iss" if decoded_jwt["iss"] != ISS
raise InvalidTokenError, "invalid aud" unless [*decoded_jwt[:aud]].include?(aud)
raise InvalidTokenError, "iat must be in the past" unless Time.zone.at(decoded_jwt["iat"]) < Time.zone.nowView on GitHub (pinned to 1c9f0bb801)