instructure/canvas-lms · error · InvalidAuthJwt

#

Error message

#{validator.error_message}

What it means

Lti::OAuth2::AuthorizationValidator#jwt decodes the posted OIDC/authorization-request JWT with the tool's shared secret, then runs it through Canvas::Security::JwtValidator with the expected audience set to the authorization URL. If validation fails (wrong aud, missing/expired claims, etc.), InvalidAuthJwt is raised with the validator's error_message as the message.

Solutions

  1. Read validator.error_message in the raised exception to see the exact failing claim and fix it in the tool's JWT
  2. Set the JWT aud to the Canvas OIDC authorization endpoint URL configured for the tool
  3. Ensure required claims (iss, sub, iat, exp, aud) are present and temporally valid
  4. Verify the tool platform config points at the correct Canvas instance/authorization URL

Example fix

// before
const payload = {iss: clientId, aud: 'https://tool.example.com/callback', sub: clientId, iat, exp};
// after
const payload = {iss: clientId, aud: 'https://canvas.example.com/api/lti/authorize_redirect', sub: clientId, iat, exp};
Defensive patterns

Strategy: validation

Validate before calling

const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString());
assert(payload.aud === authorizationUrl, 'aud must be the Canvas authorization URL');
assert(payload.iss && payload.sub && payload.iat && payload.exp, 'required claims missing');
assert(payload.exp > Date.now()/1000, 'jwt expired');

Type guard

function authJwtLooksValid(jwt, authorizationUrl) {
  try {
    const p = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString());
    const audOk = Array.isArray(p.aud) ? p.aud.includes(authorizationUrl) : p.aud === authorizationUrl;
    return audOk && !!p.iss && !!p.sub && p.exp > Date.now() / 1000;
  } catch { return false; }
}

Try / catch

begin
  validator.jwt
rescue Lti::OAuth2::AuthorizationValidator::InvalidAuthJwt => e
  Rails.logger.warn("auth jwt invalid: #{e.message}")
  render json: {error: 'invalid_request'}, status: :bad_request
end

Prevention

When it happens

Trigger: Third-party tool POSTing an authorization-request (OIDC launch) JWT whose claims fail JwtValidator checks - typically aud not matching the Canvas authorization_url, missing sub/iat/exp, or stale iat.

Common situations: Tools configured with the wrong Canvas login/authorization URL, missing openid-configuration discovery, custom launch flows omitting claims Canvas requires, and environment/domain mismatches between the tool config and the Canvas host receiving the launch.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


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

Appendix: source

Thrown at lib/lti/oauth2/authorization_validator.rb:47

      class InvalidAuthJwt < StandardError
      end

      class MissingAuthorizationCode < StandardError
      end

      def initialize(jwt:, authorization_url:, code: nil, context:)
        @raw_jwt = jwt
        @authorization_url = authorization_url
        @code = code
        @context = context
      end

      def jwt
        @_jwt ||= begin
          validated_jwt = JSON::JWT.decode @raw_jwt, jwt_secret
          validator = Canvas::Security::JwtValidator.new jwt: validated_jwt, expected_aud: @authorization_url, override_sub: sub
          unless validator.valid?
            raise InvalidAuthJwt, validator.error_message
          end

          validated_jwt
        end
      end

      alias_method :validate!, :jwt

      def tool_proxy
        @tool_proxy ||=
          if (tp = ToolProxy.where(guid: unverified_jwt[:sub], workflow_state: "active").first)
            developer_key = tp.product_family.developer_key
            raise InvalidAuthJwt, "the Developer Key is not active or available in this environment" if developer_key.present? && !developer_key.usable?

            ims_tool_proxy = ::IMS::LTI::Models::ToolProxy.from_json(tp.raw_data)
            unless ims_tool_proxy.enabled_capabilities.intersect?(["Security.splitSecret", "OAuth.splitSecret"])
              raise InvalidAuthJwt, "the Tool Proxy must be using a split secret"
            end

View on GitHub (pinned to 1c9f0bb801)