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

  1. Mint a fresh token via Lti::OAuth2::AccessToken.create_jwt (or from_jwt on a new JWT) and retry
  2. Process launch/service payloads promptly instead of storing tokens for later use
  3. 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

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


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.now

View on GitHub (pinned to 1c9f0bb801)