Behind the Scenes: How Selective Image Guard Protects Your Images
In today’s digital world, a creator’s visual content is often their most valuable asset. Whether you’re a photographer, a designer, or a blogger, the images you produce are integral to your brand and livelihood. However, they are also incredibly vulnerable to unauthorized use, scraping, and outright theft. Protecting your work is no longer just an option, it’s a necessity.
While no client-side technique can offer 100% protection against a determined attacker, you can implement a strong deterrent to stop most casual users and automated bots. In this post, I’ll show you how the WordPress plugin Selective Image Guard does exactly that. We’ll explore the code behind a layered defense, from a custom media library setting to clever URL manipulation and real-time screenshot detection. Let’s see how it’s built a guard for your images.
A Protected Image in Action
Before we dive into the code, here’s a demo image that’s been protected with the plugin. You can see how the image appears normally until you try to perform a protected action, at which point the plugin’s defenses kick in.
Under the Hood: How the Selective Image Guard Plugin Works
Let’s break down the code of Selective Image Guard to see how it protects the images of your website.
The Constructor: The Master Controller
The heart of the plugin is the class SELEIMGU_Image_ProtectorIts constructor
__construct() method is automatically called when the plugin is loaded, and its main job is to set up all the hooks (actions and filters) that make the plugin work.
public function __construct() {
add_action('send_headers', [$this, 'protect_image']);
// ... other hooks ...
if( is_admin() ) {
// ... admin hooks ...
}
else{
// ... front-end hooks ...
}
}
Notice the if ( is_admin() ) check. This allows the plugin to load different functionality depending on whether the user is in the WordPress dashboard (the admin area) or on the public-facing side of the website.
Part 1: The Admin Side (Adding the Checkbox)
On the admin side, the plugin’s job is simple: give you a way to select which images to protect.
add_media_library_checkbox(): This function uses theattachment_fields_to_editfilter to insert a new checkbox field directly into the media library’s image editing screen. This is a great way to integrate new options into the WordPress UI.save_attachment_fields(): When you save your changes to an image, this function is triggered by theattachment_fields_to_savefilter. It checks if the “Protect this image” checkbox was checked and saves that state in the database usingupdate_post_meta. This is how WordPress remembers your choice.
Part 2: The Front-End Protection
This is where the magic happens. On the front end, the plugin uses a clever filtering system to alter how protected images are loaded.
filter_image_url(),filter_image_src(), andfilter_image_attrs(): These functions are hooked into WordPress’s core image filters. Their purpose is to change the image’s URL from something likeyoursite.com/uploads/my-image.jpgto a custom URL that the plugin can control, likeyoursite.com/protect-image/?sig-prot=1&id=123. The addedsig-protquery parameter acts as a key to signal that this image is protected. Theidparameter tells the plugin which image to look for.protect_image(): This is the most crucial part of the protection. It’s hooked intosend_headers, which fires very early in the request process. Before WordPress even tries to find a page to display, this function checks if thesig-protquery parameter exists. If it does, the function then loads a separate PHP file (inc/protect-image.php) which is responsible for securely serving the image content directly. Theexitat the end stops WordPress from trying to load a regular page and ensures only the image is served. Once the image is printed, we can stop the PHP parser.
Part 3: The `protect-image.php` File
This file is the final security gatekeeper. It’s the one actually responsible for serving the image file to the browser, but only after a series of stringent checks to ensure the request is legitimate. Let’s look at the key steps:
<?php
defined('ABSPATH') || exit; // Prevent direct access
$referer = isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : false;
$home_url = get_home_url();
if (
( ! $referer || empty($referer) || strpos( $referer, $home_url ) !== 0 )
|| ( ! isset( $_GET['id'] ) || empty( $_GET['id'] ) ) // phpcs:ignore WordPress.Security.NonceVerification
|| ( ! isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) || sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_DEST'] ) ) !== 'image' ) // phpcs:ignore WordPress.Security.NonceVerification
|| ( isset( $_SERVER['[HTTP_SEC_FETCH_MODE]'] ) && sanitize_text_field( wp_unslash( $_SERVER['[HTTP_SEC_FETCH_MODE]'] ) ) === 'navigate' ) // phpcs:ignore WordPress.Security.NonceVerification
) {
header( 'HTTP/1.0 403 Forbidden' );
wp_die( 'Direct access not allowed', 403 );
}
$image_id = sanitize_text_field( wp_unslash( $_GET['id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
// Get the full file path
$file_path = get_attached_file( $image_id );
if( isset( $_GET['size_slug'] ) && ! empty( $_GET['size_slug'] ) ) {
$size_slug = sanitize_text_field( wp_unslash( $_GET['size_slug'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
$info = pathinfo( $file_path );
$file_path = $info['dirname'] . '/' . $info['filename'] . $size_slug . '.' . $info['extension'];
}
if ( file_exists( $file_path ) ) {
if( ! function_exists( 'WP_Filesystem' ) ) {
require_once( ABSPATH . 'wp-admin/includes/file.php' );
}
global $wp_filesystem;
WP_Filesystem();
// Send headers
status_header( 200 );
header( 'Selective-Image-Guard: Protected' );
header( 'Content-Type: ' . mime_content_type( $file_path ) );
header( 'Content-Length: ' . filesize( $file_path ) );
header( 'Expires: Thu, 01 Jan 1970 00:00:00 GMT' ); // Expired in the past
header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
header( 'Cache-Control: post-check=0, pre-check=0', false ); // for IE
header( 'Pragma: no-cache'); // HTTP 1.0
// Output the image content
echo $wp_filesystem->get_contents( $file_path ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
exit;
}
This script’s first job is to run a series of security checks to prevent unauthorized access. The most important checks are:
- Hotlinking Check: It verifies that the request is coming from your own website by examining the **HTTP Referer** header. If the referer is not your domain, it’s blocked.
- Browser Request Headers: It inspects headers like
HTTP_SEC_FETCH_DESTto confirm the browser’s intent. This prevents a user from simply typing the protected URL directly into their address bar.
If any of these checks fail, the script immediately sends a **403 Forbidden** header and exits. If the checks pass, the script then proceeds to:
- Locate the File: Using the image ID and any optional size information, it constructs the full file path to the image on the server.
- Set Security Headers: It sends specific HTTP headers to the browser, including the correct
Content-TypeandContent-Length. Most importantly, it sets **no-cache headers** to ensure the image is never stored in the cache, guaranteeing that all future requests for the image go through the security script. - Output the Image: Finally, it uses the WordPress filesystem to read the image file’s content and output it directly to the browser, completing the secure delivery.
Bonus Features: JavaScript and Caching
The plugin also includes some clever features to make sure it works smoothly.
- JavaScript Detection: The
inject_detection_script()andremove_body_class()functions are a neat trick. They add a special CSS class (image-protector-no-script) to the body of the page. Then, a small JavaScript snippet is injected to remove that class. If JavaScript is disabled, the class remains, and a CSS rule can be used to hide the protected images or a<noscript>tag can redirect the user. - Caching Prevention: The
prevent_caching_issues()function handles potential problems with the most popular caching plugins. - Image Size Slug: The
get_image_size_slug()function is a helper that intelligently figures out if the image requested is a thumbnail or a full-size image, allowing the plugin to serve the correct version.
Conclusion
The Selective Image Guard plugin is a powerful example of how to use WordPress hooks and filters to build a robust, selective protection system. By separating the user-facing and backend logic and using URL filtering, it provides a solid defense against hotlinking without sacrificing the user experience.
If you have any questions about a specific function or how to extend the plugin’s functionality, just let me know!
Important Note
This plugin provides a **deterrent**, not a foolproof solution. No client-side technique can offer 100% protection against a determined attacker.
For this plugin to work correctly with a CDN, you must bypass caching for any URLs that contain the sig-prot=1 parameter.
For **Selective Image Guard** to function correctly, any caching mechanism on your website—including browser, server, and CDN caching (like Cloudflare, Varnish, etc.)—must **exclude URLs that contain `/protect-image/` and `sig-prot=1`**. These URLs must be served directly from your server without being cached.
Ready to Protect Your Images?
By combining powerful server-side security with a smart front-end script, the **Selective Image Guard** plugin offers a robust, multi-layered defense against image theft and scraping. You’ve seen how the code works behind the scenes to give you control over your valuable visual content.
Now, it’s time to put that power in your hands. You can download the plugin and get started on protecting your website’s images today.
The plugin is downloadable by clicking on the button below…
Fantastico plugin, come sempre!
Grazie mille Michele! Le tue osservazioni sono state di grande aiuto per rendere il plugin ancora più efficace.