Plugin Updates: Securing Download Links

Ever since the release of the Plugin Update Checker library, one of the most common questions asked has been this: “How can I secure my download links so that only users who have purchased my plugin can download an update?”

In this post I’ll try to answer that question in the context of using WP Update Server to serve plugin updates. I will also provide a couple of practical examples from one of my commercial plugins.

Lets get started. There are many ways to secure update downloads, but most of them boil down to this:

  1. Give some kind of security token to each user. This could be a login + password to a membership site, a license key, or something more esoteric.
  2. Whenever someone installs your plugin on their site, ask them to enter the key/log in/whatever.
  3. Modify the plugin to append the token to each update request.
  4. Write a server script that verifies the token before it allows download.

Choose a Security Token

How you implement the first two steps will vary greatly depending on your plugin UI and what online store, cart software, or membership plugin you use. You might already have some kind of customer authentication mechanism in place that just needs a bit of tweaking to be used for updates, or you might need to build your own from scratch. There’s no “one size fits all” solution here.

Personally, I prefer to use license keys. Whenever someone buys my Admin Menu Editor Pro plugin, the order processing script generates a random key, stores it in the database, and sends the key and a download link to the customer’s email. Then, when they install the plugin, there’s a link to enter the license key.

I won’t include the license management code here because it’s beyond the scope of this post and built for that specific plugin, but the user interface looks something like this (click to enlarge):

Admin Menu Editor Pro entry on the "Plugins" page

Add the Token To Update Requests

Now, how do we add our security token to each update request? You can do that by using the addQueryArgFilter($callback) method of the update checker. The callback function will receive an associative array of query arguments. Just add the token to the list and return the modified array.

Here’s an example:

/* ... Code that initializes the update checker ... */

//Add the license key to query arguments.
$updateChecker->addQueryArgFilter('wsh_filter_update_checks');
function wsh_filter_update_checks($queryArgs) {
	$settings = get_option('my_plugin_settings');
	if ( !empty($settings['license_key']) ) {
		$queryArgs['license_key'] = $settings['license_key'];
	}
	return $queryArgs;
}

Use the Token To Authorize Downloads

Finally, let’s make the update server verify the security token before it lets the user download an update. To do that, you’ll need to create a custom server class (see Extending the server) and override at least the Wpup_UpdateServer::checkAuthorization($request) method. Here’s what you should do in this method:

  1. Retrieve the query argument(s) that contains the token by using $request->param('arg_name').
  2. Verify the token. Again, this part is up to you. You could look it up in a database, use a checksum to validate it, or something else.
  3. If the token is good, you don’t need to do anything special.
  4. If the token is invalid, call $this->exitWithError('Error message') to output an error and stop script execution.

Below is a simplified version of the script I use to implement secure updates for  Admin Menu Editor Pro. It is slightly more advanced than the above outline, but the general idea is the same.

(Again, license management is beyond the scope of this post, so I’ve omitted most of the code that deals with loading and validating licenses. Just treat verifyLicenseExists() and other licensing functions as pseudo-code.)

class SecureUpdateServer extends Wpup_UpdateServer {
	protected $licenseServer;

	public function __construct($serverUrl, $licenseServer) {
		parent::__construct($serverUrl);
		$this->licenseServer = $licenseServer;
	}

	protected function initRequest($query = null, $headers = null) {
		$request = parent::initRequest($query, $headers);

		//Load the license, if any.
		$license = null;
		if ( $request->param('license_key') ) {
			$result = $this->licenseServer->verifyLicenseExists(
				$request->slug,
				$request->param('license_key')
			);
			if ( is_wp_error($result) ) {
				//If the license doesn't exist, we'll output an invalid dummy license.
				$license = new Wslm_ProductLicense(array(
					'status' => $result->get_error_code(),
					'error' => array(
						'code' => $result->get_error_code(),
						'message' => $result->get_error_message(),
					),
				));
			} else {
				$license = $result;
			}
		}

		$request->license = $license;
		return $request;
	}

	protected function filterMetadata($meta, $request) {
		$meta = parent::filterMetadata($meta, $request);

		//Include license information in the update metadata. This saves an HTTP request
		//or two since the plugin doesn't need to explicitly fetch license details.
		$license = $request->license;
		if ( $license !== null ) {
			$meta['license'] = $this->licenseServer->prepareLicenseForOutput($license);
		}

		//Only include the download URL if the license is valid.
		if ( $license && $license->isValid() ) {
			//Append the license key or to the download URL.
			$args = array( 'license_key' => $request->param('license_key') );
			$meta['download_url'] = self::addQueryArg($args, $meta['download_url']);
		} else {
			//No license = no download link.
			unset($meta['download_url']);
		}

		return $meta;
	}

	protected function checkAuthorization($request) {
		parent::checkAuthorization($request);

		//Prevent download if the user doesn't have a valid license.
		$license = $request->license;
		if ( $request->action === 'download' && ! ($license && $license->isValid()) ) {
			if ( !isset($license) ) {
				$message = 'You must provide a license key to download this plugin.';
			} else {
				$error = $license->get('error');
				$message = isset($error) ? $error : 'Sorry, your license is not valid.';
			}
			$this->exitWithError($message, 403);
		}
	}
}

If you have any questions, feel free to leave a comment.

Related posts :

38 Responses to “Plugin Updates: Securing Download Links”

  1. Jānis Elsts says:

    I’m guessing that you didn’t get my response to your contact form submission (sent on Oct 22), right?

    Anyway, as far as I can tell, you’ve pasted the example code almost without modification. Does your plugin actually have a “my_plugin_settings” option that contains an associative array with a “license_key” key? If it doesn’t, then the wsh_filter_update_checks won’t do anything. It can’t append a license key if there is no license key. You’ll need to change the example code to retrieve the key from the place where your plugin actually stores it.

    … why the function wsh_filter_update_checks() is a class method?

    That was an error. Thank you for spotting it. I’ve fixed it.

  2. Raaas says:

    Thank for sharing this code. I will use this on my new website. If I have any doubt, can you give me soon suggestion.

  3. Ahana Sharma says:

    Finally found it.
    Thanks for this helpful guide. You made it look simple.

    Thanks Again

  4. Hello Jānis,

    I am quite confused: How does license checking work with github, gitlab or (the one I use) bitbucket integration?
    I have created my server class and handled license checking just fine, but it seems creating an instance of Puc_v4p4_Vcs_PluginUpdateChecker with a Puc_v4p4_Vcs_BitBucketApi bypasses license checking. Am I missing something?

  5. Jānis Elsts says:

    You’re not missing anything, this doesn’t work with GitHub/GitLab/BiBucket. I posted a longer response on your GitHub issue:
    https://github.com/YahnisElsts/plugin-update-checker/issues/199

  6. allver says:

    helpful guide, thanks

  7. Patrick says:

    Hi Janis,

    I absolutely love wp-update-server and wp-update-checker. It just works! And that is almost unique nowadays 🙂 Easy to use and still robust. Perfect!

    I would like to implement licensing. You took that out of this article (for logical reasons), but seeing how great your other work is, I was wondering if you could share (or if already shared, where?) the licensing module.

    Many thanks in advance 🙂

  8. Jānis Elsts says:

    The licensing solution is still private. The main obstacle to sharing it with anyone is that it was designed for my own product(s) and it’s very limited in scope. It only supports one payment system (PayPal), one licensing mode (a license key per purchase), and a very small amount of licensing options (site limits and add-ons). You wouldn’t be able to just plug it into your online store and have it work.

    The customer UI for entering or changing license keys is alright, but the admin backend is non-existent. When I need to look up someone’s license key, I use phpMyAdmin.

  9. Varun Sharma says:

    Thank you for sharing.

  10. Anil Kumar says:

    Thanks for sharing the post with us.

  11. Ruhul Amin says:

    Wow, it’s amazing. Thanks for sharing such information with us!!

  12. Urano says:

    Great work
    I have my own repo for plugins I develop, and my customer can update with a click…
    After 7 years of this post works very fine.
    Thank you!

  13. visitwowbots says:

    hey just wanted to thank ya

  14. Sanya says:

    I actually added your blog to my favourite and will look forward for more updates. Great Job, Keep it up. First of all let me tell you, you have got a great blog .I am interested in looking for more of such topics and would like to have further information. Hope to see the next blog soon.

  15. pop man says:

    Please can you really guide me on how to make my mp3 file path hidden so that users cannot see the source?
    For example I want something like this.
    https://example.com/mymp3folder/senorita.mp3
    to https://example.com/mymp3folder/

    Is it possible to get that file name senorita without adding it on the download url path as the first link?

    I use wordpress and will like if this is possible with wordpress. Thanks

  16. Jānis Elsts says:

    It sounds like that is not related to plugin updates. The approach discussed in this post will not work in your case. You may need to look for a solution elsewhere.

  17. Masud Rana says:

    It used to work great for few years until recently it stopped working 🙁

    here is my code :
    require __DIR__ . ‘/loader.php’;
    class SecureUpdateServer extends Wpup_UpdateServer {
    protected $licenseServer;

    protected function initRequest($query = null, $headers = null) {
    $request = parent::initRequest($query, $headers);

    //Load the license, if any.
    $license = null;

    $request->license = $request->param(‘license_key’);
    return $request;
    }

    protected function isValidLicense( $license ){
    // return true;
    global $wpdb;
    $software_user_list = $wpdb->prefix.’software_user_list’;
    $get_license = $wpdb->get_var(“SELECT COUNT(*) FROM $software_user_list WHERE `license_key` = ‘$license'” );
    return ( $get_license ) ? true : false;
    }

    protected function filterMetadata($meta, $request) {
    $meta = parent::filterMetadata($meta, $request);

    //Include license information in the update metadata. This saves an HTTP request
    //or two since the plugin doesn’t need to explicitly fetch license details.
    $license = $request->license;

    //Only include the download URL if the license is valid.
    if ( $this->isValidLicense( $license ) ) {
    //Append the license key or to the download URL.
    $args = array( ‘license_key’ => $request->param(‘license_key’) );
    $meta[‘download_url’] = self::addQueryArg($args, $meta[‘download_url’]);
    } else {
    //No license = no download link.
    unset($meta[‘download_url’]);
    }

    return $meta;
    }

    protected function checkAuthorization($request) {
    parent::checkAuthorization($request);

    //Prevent download if the user doesn’t have a valid license.
    $license = $request->license;
    if ( $request->action === ‘download’ && ! ( $this->isValidLicense( $license ) ) ) {
    if ( !isset($license) ) {
    $message = ‘You must provide a license key to download this plugin.’;
    } else {
    // $error = $license->get(‘error’);
    $message =’Sorry, your license is not valid.’;
    }
    $this->exitWithError($message, 403);
    }
    }
    }
    $server = new SecureUpdateServer();
    $server->handleRequest();

    The error I am getting while updating :
    The package could not be installed. PCLZIP_ERR_BAD_FORMAT (-10) : Invalid End of Central Dir Record size : 16
    Note: it’s not memory issue

  18. Jānis Elsts says:

    That error could mean a few different things, but I think the most likely possibilities are:

    a) There’s something wrong with the ZIP file. Try recreating and reuploading it.

    b) The server is throwing an error somewhere so it either doesn’t output the whole ZIP file or it adds some extraneous data to the output, like an error message. This could prevent WordPress from parsing the ZIP file. Try manually downloading the file and verify that it actually works (i.e. you can open the archive and extract files). Also, check the PHP error log for errors related to the update server.

Leave a Reply