{"record":{"id":"3e0091d3a3bdf209","repo":"instructure/canvas-lms","slug":"invalid-logout-service-token","errorCode":null,"errorMessage":"Invalid logout service token","messagePattern":"Invalid logout service token","errorType":"http","errorClass":"BasicLTI::BasicOutcomes::Unauthorized","httpStatus":401,"severity":"error","filePath":"app/models/lti/logout_service.rb","lineNumber":53,"sourceCode":"    end\n\n    Token = Struct.new(:tool, :pseudonym, :timestamp, :nonce) do\n      def self.create(tool, pseudonym)\n        Token.new(tool, pseudonym, Time.zone.now, SecureRandom.hex(8))\n      end\n\n      def serialize\n        key = tool.shard.settings[:encryption_key]\n        payload = [tool.id, pseudonym.id, timestamp.to_i, nonce].join(\"-\")\n        \"#{payload}-#{Canvas::Security.hmac_sha1(payload, key)}\"\n      end\n\n      def self.parse_and_validate(serialized_token)\n        parts = serialized_token.split(\"-\")\n        tool = Lti::ToolFinder.find(parts[0].to_i)\n        key = tool.shard.settings[:encryption_key]\n        unless parts.size == 5 && Canvas::Security.hmac_sha1(parts[0..-2].join(\"-\"), key) == parts[-1]\n          raise BasicLTI::BasicOutcomes::Unauthorized, \"Invalid logout service token\"\n        end\n\n        pseudonym = Pseudonym.find(parts[1].to_i)\n        timestamp = parts[2].to_i\n        nonce = parts[3]\n        unless Time.now.to_i - timestamp < Lti::LogoutService::TOKEN_EXPIRATION\n          raise BasicLTI::BasicOutcomes::Unauthorized, \"Logout service token has expired\"\n        end\n\n        Token.new(tool, pseudonym, timestamp, nonce)\n      end\n    end\n\n    Runner = Struct.new(:callbacks) do\n      def perform\n        callbacks.each_value do |callback|\n          InstrumentTLSCiphers.without_tls_metrics do\n            CanvasHttp.get(URI.parse(callback).to_s)","sourceCodeStart":35,"sourceCodeEnd":71,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/models/lti/logout_service.rb#L35-L71","documentation":"LogoutService.parse_and_validate deserializes a signed, hyphen-joined token containing tool id, pseudonym id, timestamp, nonce and an HMAC-SHA1 signature computed with the tool shard's encryption_key. It raises BasicLTI::BasicOutcomes::Unauthorized when the token doesn't have exactly 5 parts or the recomputed HMAC does not match the final part.","triggerScenarios":"Passing a token with wrong number of segments, or a token whose signature was computed with a different/outdated encryption key, or a tampered token, to LogoutService.parse_and_validate.","commonSituations":"Tool shard encryption_key rotated after token issuance; tokens copied across environments/shards; hand-built or truncated tokens; different shard settings lacking :encryption_key.","solutions":["Verify the tool shard settings[:encryption_key] is the same key used when the token was generated","Regenerate the logout service token after rotating the encryption key","Check the serialized token has exactly 5 hyphen-separated parts with no stray hyphens in payload values","Rescue BasicLTI::BasicOutcomes::Unauthorized and reject the logout request with 401"],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"valid = token.count(\"-\") == 4 # 5 parts expected","typeGuard":null,"tryCatchPattern":"begin\n  token = Lti::LogoutService.parse_and_validate(serialized)\nrescue BasicLTI::BasicOutcomes::Unauthorized\n  head :unauthorized\nend","preventionTips":["Keep shard encryption_key stable or regenerate tokens after rotation","Never transport tokens across environments","Rescue BasicLTI::BasicOutcomes::Unauthorized on all token parsing paths"],"tags":["lti","hmac","token-validation"],"backgroundTag":"checksum-mismatch","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}