instructure/canvas-lms · error · AdvantageErrors::InvalidAccessTokenClaims

Access token expired

Error message

Access token expired

What it means

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.

Solutions

  1. Request a fresh access token from the Canvas OAuth2 token endpoint and retry the call
  2. Shorten the client's token cache TTL to be safely below the exp lifetime (e.g. 50 minutes for a 1-hour token)
  3. Check NTP/clock synchronization on the tool server if tokens 'expire' immediately

Example fix

// before
const token = cachedToken;
await fetch(nrpsUrl, {headers: {Authorization: `Bearer ${token}`}});
// after
const token = isExpired(cachedToken) ? await fetchNewToken() : cachedToken;
await fetch(nrpsUrl, {headers: {Authorization: `Bearer ${token}`}});
Defensive patterns

Strategy: try-catch

Validate before calling

const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
if (payload.exp * 1000 <= Date.now()) await refreshAccessToken();

Type guard

function tokenIsFresh(token, skewSec = 30) {
  try {
    const {exp} = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
    return typeof exp === 'number' && exp - skewSec > Math.floor(Date.now() / 1000);
  } catch { return false; }
}

Try / catch

begin
  call_nrps(token)
rescue Lti::IMS::AdvantageErrors::InvalidAccessTokenClaims => e
  raise unless e.message.include?('expired')
  token = fetch_new_token
  retry
end

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Related errors


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

Appendix: source

Thrown at lib/lti/ims/advantage_access_token.rb:47

      def validate!(expected_audience)
        validate_claims!(expected_audience)
        self
      rescue Canvas::Security::InvalidToken => e
        case e.cause
        when JSON::JWT::InvalidFormat
          raise AdvantageErrors::MalformedAccessToken, e
        when JSON::JWS::UnexpectedAlgorithm
          raise AdvantageErrors::InvalidAccessTokenSignatureType, e
        when JSON::JWS::VerificationFailed
          raise AdvantageErrors::InvalidAccessTokenSignature, e
        else
          raise AdvantageErrors::InvalidAccessToken.new(e, api_message: "Access token invalid - signature likely incorrect")
        end
      rescue JSON::JWT::Exception => e
        raise AdvantageErrors::InvalidAccessToken, e
      rescue Canvas::Security::TokenExpired => e
        raise AdvantageErrors::InvalidAccessTokenClaims.new(e, api_message: "Access token expired")
      rescue AdvantageErrors::AdvantageServiceError
        raise
      rescue => e
        raise AdvantageErrors::AdvantageServiceError, e
      end

      def validate_claims!(expected_audience)
        validator = Canvas::Security::JwtValidator.new(
          jwt: decoded_jwt,
          expected_aud: expected_audience,
          require_iss: true,
          skip_jti_check: true,
          max_iat_age: 60.minutes
        )

        # In this case we know the error message can just be safely shunted into the API response (in other cases
        # we're more wary about leaking impl details)
        unless validator.valid?

View on GitHub (pinned to 1c9f0bb801)