{"record":{"id":"f68fe52b20cf67dd","repo":"instructure/canvas-lms","slug":"access-token-expired","errorCode":null,"errorMessage":"Access token expired","messagePattern":"Access token expired","errorType":"exception","errorClass":"AdvantageErrors::InvalidAccessTokenClaims","httpStatus":401,"severity":"error","filePath":"lib/lti/ims/advantage_access_token.rb","lineNumber":47,"sourceCode":"\n      def validate!(expected_audience)\n        validate_claims!(expected_audience)\n        self\n      rescue Canvas::Security::InvalidToken => e\n        case e.cause\n        when JSON::JWT::InvalidFormat\n          raise AdvantageErrors::MalformedAccessToken, e\n        when JSON::JWS::UnexpectedAlgorithm\n          raise AdvantageErrors::InvalidAccessTokenSignatureType, e\n        when JSON::JWS::VerificationFailed\n          raise AdvantageErrors::InvalidAccessTokenSignature, e\n        else\n          raise AdvantageErrors::InvalidAccessToken.new(e, api_message: \"Access token invalid - signature likely incorrect\")\n        end\n      rescue JSON::JWT::Exception => e\n        raise AdvantageErrors::InvalidAccessToken, e\n      rescue Canvas::Security::TokenExpired => e\n        raise AdvantageErrors::InvalidAccessTokenClaims.new(e, api_message: \"Access token expired\")\n      rescue AdvantageErrors::AdvantageServiceError\n        raise\n      rescue => e\n        raise AdvantageErrors::AdvantageServiceError, e\n      end\n\n      def validate_claims!(expected_audience)\n        validator = Canvas::Security::JwtValidator.new(\n          jwt: decoded_jwt,\n          expected_aud: expected_audience,\n          require_iss: true,\n          skip_jti_check: true,\n          max_iat_age: 60.minutes\n        )\n\n        # In this case we know the error message can just be safely shunted into the API response (in other cases\n        # we're more wary about leaking impl details)\n        unless validator.valid?","sourceCodeStart":29,"sourceCodeEnd":65,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/lib/lti/ims/advantage_access_token.rb#L29-L65","documentation":"During AdvantageAccessToken#validate!, Canvas::Security.decode_jwt verifies the JWT exp claim and raises Canvas::Security::TokenExpired when it has lapsed. validate! translates this into AdvantageErrors::InvalidAccessTokenClaims with the API message 'Access token expired', indicating the token was structurally valid but used after its expiration time.","triggerScenarios":"Any LTI Advantage service request (NRPS/AGS) where the client_credentials access token's exp claim is in the past at the time Canvas decodes it.","commonSituations":"Tools caching access tokens beyond their 1-hour lifetime, clock skew between tool server and Canvas, retrying with a stale cached token after a long job pause, or long-running batch jobs reusing one token.","solutions":["Request a fresh access token from the Canvas OAuth2 token endpoint and retry the call","Shorten the client's token cache TTL to be safely below the exp lifetime (e.g. 50 minutes for a 1-hour token)","Check NTP/clock synchronization on the tool server if tokens 'expire' immediately"],"exampleFix":"// before\nconst token = cachedToken;\nawait fetch(nrpsUrl, {headers: {Authorization: `Bearer ${token}`}});\n// after\nconst token = isExpired(cachedToken) ? await fetchNewToken() : cachedToken;\nawait fetch(nrpsUrl, {headers: {Authorization: `Bearer ${token}`}});","handlingStrategy":"try-catch","validationCode":"const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());\nif (payload.exp * 1000 <= Date.now()) await refreshAccessToken();","typeGuard":"function tokenIsFresh(token, skewSec = 30) {\n  try {\n    const {exp} = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());\n    return typeof exp === 'number' && exp - skewSec > Math.floor(Date.now() / 1000);\n  } catch { return false; }\n}","tryCatchPattern":"begin\n  call_nrps(token)\nrescue Lti::IMS::AdvantageErrors::InvalidAccessTokenClaims => e\n  raise unless e.message.include?('expired')\n  token = fetch_new_token\n  retry\nend","preventionTips":["Cache access tokens with a TTL safely below exp (e.g. 50 min for a 1-hour token)","Check expiry before each request and refresh when within a small skew window","Keep clocks NTP-synchronized","Never reuse tokens across batch jobs longer than their lifetime"],"tags":["lti","jwt","expired-token","oauth2"],"backgroundTag":"jwt-token-expired","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"}