How protect specific images from unauthorized downloads and scraping

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_Protector
Its 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 the attachment_fields_to_edit filter 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 the attachment_fields_to_save filter. It checks if the “Protect this image” checkbox was checked and saves that state in the database using update_post_meta. This is how WordPress remembers your choice.
How to protect an image from unauthorized downloads

 

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(), and filter_image_attrs(): These functions are hooked into WordPress’s core image filters. Their purpose is to change the image’s URL from something like yoursite.com/uploads/my-image.jpg to a custom URL that the plugin can control, like yoursite.com/protect-image/?sig-prot=1&id=123. The added sig-prot query parameter acts as a key to signal that this image is protected. The id parameter tells the plugin which image to look for.
  • protect_image(): This is the most crucial part of the protection. It’s hooked into send_headers, which fires very early in the request process. Before WordPress even tries to find a page to display, this function checks if the sig-prot query 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. The exit at 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_DEST to 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-Type and Content-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() and remove_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…

2 thoughts on “How protect specific images from unauthorized downloads and scraping

Comments are closed.