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:
- 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.
- Whenever someone installs your plugin on their site, ask them to enter the key/log in/whatever.
- Modify the plugin to append the token to each update request.
- 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):
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:
- Retrieve the query argument(s) that contains the token by using
$request->param('arg_name')
. - 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.
- If the token is good, you don’t need to do anything special.
- 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 :
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.That was an error. Thank you for spotting it. I’ve fixed it.
Thank for sharing this code. I will use this on my new website. If I have any doubt, can you give me soon suggestion.
Finally found it.
Thanks for this helpful guide. You made it look simple.
Thanks Again
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?
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
helpful guide, thanks
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 🙂
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.
Thank you for sharing.
Thanks for sharing the post with us.
Wow, it’s amazing. Thanks for sharing such information with us!!
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!
hey just wanted to thank ya
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.
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
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.
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
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.