instructure/canvas-lms · error · InvalidTokenError

invalid iss

Error message

invalid iss

What it means

Lti::OAuth2::AccessToken#validate! checks that the JWT's iss claim equals the constant 'Canvas'. Tokens issued by Canvas always carry iss: 'Canvas'; a mismatch means the token was not minted by this code path or was tampered with, so InvalidTokenError 'invalid iss' is raised.

Solutions

  1. Ensure the token was produced by Canvas via Lti::OAuth2::AccessToken.create_jwt / the jwt builder with iss: 'Canvas'
  2. Inspect the token payload (base64-decode) and correct the iss claim to 'Canvas'
  3. If you meant to validate a tool-issued JWT, use the appropriate validator (e.g. AuthorizationValidator or AdvantageAccessToken) instead

Example fix

# before: custom issuer
body = {iss: 'my-tool', sub:, exp:, aud:, iat:, nbf:, jti:}
# after
body = {iss: Lti::OAuth2::AccessToken::ISS, sub:, exp:, aud:, iat:, nbf:, jti:}
Defensive patterns

Strategy: validation

Validate before calling

iss = JSON.parse(Base64.urlsafe_decode64(jwt.split('.')[1]))['iss']
raise 'not a Canvas-issued LTI token' unless iss == 'Canvas'

Type guard

def canvas_issued_lti_jwt?(jwt)
  payload = JSON.parse(Base64.urlsafe_decode64(jwt.split('.')[1]))
  payload['iss'] == 'Canvas'
rescue JSON::ParserError, ArgumentError
  false
end

Prevention

When it happens

Trigger: Calling validate! on a JWT whose iss claim is not exactly 'Canvas' - e.g. a token minted by another issuer, hand-crafted fixtures, or tokens built with a custom/expired iss value.

Common situations: Tool vendors passing their own OIDC launch JWTs into this Canvas-internal validator, upgrading Canvas code where ISS changed, or forging test tokens with issuer set to the tool's client_id.

Related errors


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

Appendix: source

Thrown at lib/lti/oauth2/access_token.rb:53

        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

        true
      rescue InvalidTokenError
        raise
      rescue Canvas::Security::TokenExpired => e
        raise InvalidTokenError, "token has expired", e.backtrace
      rescue => e
        raise InvalidTokenError, e
      end

      def to_s
        jwt
      end

      private

View on GitHub (pinned to 1c9f0bb801)