Someone finds your product photo, copies the URL out of your page source, and drops it into their own article. Their readers see the picture. Your server reads the file off disk and sends it. You get no visit, no referral, no line in your analytics, and a slightly larger number on your bandwidth graph.
That is hotlinking, and the standard remedy is five lines of .htaccess that you have probably already pasted in. The five lines usually work. What the tutorials leave out is that the same five lines can drop your images out of Google Images, blank out every link preview your site has ever generated, and poison a CDN cache in a way that serves a 403 to your own visitors.
They also leave out the more awkward part: most sites that install hotlink protection never had a measurable problem. The order matters. Measure first, block second, and know exactly which requests you are about to throw away.
What hotlinking is, and what it is not
Hotlinking is an img tag on someone else’s page whose src points at a file on your domain. Their HTML, your bytes. A browser rendering their page opens a connection to your server, your server sends the file, and their page looks complete. Nothing was copied. If you rename the file, their page breaks.
Copying is the other thing entirely. They download the JPEG, upload it into their own media library, and serve it from their own host. Their bytes, their bandwidth, your picture. If you rename your file, their page is unaffected.
The two look identical in a browser and have nothing in common as problems. Hotlinking is a bandwidth problem with a web server fix. Copying is a licensing problem, and no server rule touches it. Almost every heated argument about stolen images turns out to be the second problem wearing the first one’s clothes.
Why PHP never sees the request
This is the mechanism that decides where the fix belongs. WordPress writes its rewrite rules through WP_Rewrite::mod_rewrite_rules() in wp-includes/class-wp-rewrite.php, and the generated block in your root .htaccess contains two conditions that matter here:
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
Requests are handed to index.php only when the requested path is not an existing file and not an existing directory. An upload at /wp-content/uploads/2026/03/kitchen-1024x683.jpg is an existing file. Apache serves it straight off disk. WordPress does not boot, init does not fire, template_redirect never runs, and every plugin on your site is asleep while those bytes leave the building.
So a PHP based hotlink blocker has only one way to work: reroute your image URLs through a PHP endpoint, so WordPress boots for every image request. On a page with thirty images plus their responsive variants, that trades a handful of static file reads for dozens of full bootstraps. It is a large permanent cost to solve a problem you may not have. Put the rule where the request already is.
Measure before you block
Your analytics cannot see hotlinking and never will. An image request executes no JavaScript, so Google Analytics, Matomo and every other client side tool are structurally blind to it. The only witnesses are your access log and, if you have one, your CDN log.
Apache’s combined format and the nginx format of the same name both put the request path in field seven, the response size in field ten and the referrer in field eleven. This is read only and safe to run on a live server:
# Top external referrers for image requests, by hit count
awk '$7 ~ /.(jpe?g|png|gif|webp|avif)([?#]|$)/ {gsub(/"/,"",$11); print $11}'
/var/log/nginx/access.log
| grep -v -e '^-$' -e 'example.com'
| sort | uniq -c | sort -rn | head -20
Hit counts flatter the problem. What you actually pay for is bytes, which sit in field ten:
# Same thing, ranked by megabytes served
awk '$7 ~ /.(jpe?g|png|gif|webp|avif)([?#]|$)/ {
gsub(/"/,"",$11);
if ($11 !~ /example.com/ && $11 != "-") bytes[$11] += $10
}
END { for (r in bytes) printf "%10.1f MB %sn", bytes[r]/1048576, r }'
/var/log/nginx/access.log | sort -rn | head -20
Swap example.com for your own domain and point the path at wherever your host keeps logs. The extension test allows a trailing query string, because cache busting parameters are common on image URLs and a plain $ anchor would skip every one of them. Rotated logs are gzipped, so add a pass with zcat access.log.*.gz if one day is not enough of a sample. Referrer strings are supplied by whoever made the request, so treat them as untrusted text and never feed them into anything that executes.
What it actually costs
Now do the arithmetic, because this is where most hotlink projects should end. Twenty thousand hotlinked requests a month at 300 KB each is roughly 6 GB. On shared hosting that is invisible. At a rate of eight cents a gigabyte it is under fifty cents. On object storage with per request charges and cold reads it can be worth attention. Put your own numbers in, because the result is almost never what the rage suggested it would be.
The cost is unevenly distributed, though, and WordPress makes it worse. A hotlinker copies whatever URL was in the page they were looking at, and on a modern theme at a wide viewport that is often one of the largest registered sizes, or the -scaled copy that core creates once an upload crosses the 2560 pixel default behind the big_image_size_threshold filter. One upload becoming a dozen files is how WordPress image sizes work, and the file that leaks is usually near the top of that stack rather than the thumbnail.
Two things fall out of the measurement that a blocking rule never gives you. First, if one referrer accounts for most of the traffic, a polite email is faster and less risky than any configuration change, and it occasionally turns into a link. Second, compare the hotlinked total against your total image egress. If your own visitors are pulling ten times more, you have an image weight problem rather than a hotlinking one, and that sits far higher on the list of things that actually make a WordPress site slow.
The referrer rule, at the server level
If the log justified it, here is the Apache version. Put it in a .htaccess file inside wp-content/uploads rather than in the site root, so that a core update rewriting the WordPress block cannot disturb it and so that the rule only ever sees upload traffic. It needs AllowOverride FileInfo or wider on that directory. Take a copy of any existing file before you edit it.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^https?://([^.]+.)?example.com [NC]
RewriteCond %{HTTP_REFERER} !^https?://([^.]+.)?google. [NC]
RewriteCond %{HTTP_REFERER} !^https?://([^.]+.)?bing.com [NC]
RewriteCond %{HTTP_REFERER} !^https?://([^.]+.)?duckduckgo.com [NC]
RewriteRule .(jpe?g|png|gif|webp|avif)$ - [F,NC]
</IfModule>
Line by line: the conditions are all negative, so the rule fires only when none of them matched. !^$ lets empty referrers through, which is not optional and is discussed below. The ([^.]+.)? group covers your bare domain and any subdomain in one expression, which is the difference between a rule that works and a week of confused bug reports. google. without a top level domain is deliberate, because Google serves image results from many country domains. [F] returns 403 directly with no redirect and no second request. [NC] makes matching case insensitive, which matters because uppercase extensions are common on camera files. And the header really is spelled HTTP_REFERER with one R, a misspelling that entered the HTTP specification early and is now permanent.
The nginx equivalent goes in your server block:
location ~* ^/wp-content/uploads/.+.(jpe?g|png|gif|webp|avif)$ {
valid_referers none blocked server_names
*.example.com example.com
google.* bing.com duckduckgo.com;
if ($invalid_referer) {
return 403;
}
expires 30d;
access_log off;
}
none matches a request with no Referer header at all. blocked matches a header that was present but stripped of its scheme by a proxy or firewall. server_names pulls in the hostnames already declared in the block. The two trailing directives matter: a new, more specific location takes the request away from whatever broad static asset location you already had, so any expires, add_header or access_log settings that lived there must be copied in or they are silently lost. Test with nginx -t before reloading.
The three ways it goes wrong are all avoidable if the rule is written with the exceptions in it from the start.
Build it below. You give it your domains, both with and without www, and tick the referrers that have to keep working, image search, the social networks that fetch previews, feed readers. It writes the rule for Apache, for Nginx and as a Cloudflare expression, and there is a test field where you can type a referrer and see whether your own rule would have let it through, which is the check that stops you blocking yourself.
Hotlink protection builder
Builds hotlink rules for Apache, nginx, Cloudflare and PHP that keep image search, feed readers and link previews working, and lets you type a referrer to see what your own rule would do with it. Everything is worked out in this browser tab, nothing is sent anywhere.
Both spellings belong in here, with www and without, plus every other host your pages are served under. A missing spelling is how people lock themselves out. Domains with accents go in as the punycode form, xn--mller-kva.de.
A direct hit, a bookmark, a link out of a mail programme and a good many privacy settings send no referrer at all. Turning this off is the most common cause of "my own images are gone".
An entry here covers the host and its subdomains. Your own domains above are taken exactly as written, so a subdomain of yours only passes once it stands in that box.
The check reads the Apache block line by line and answers with the rule that is printed above, not with a second copy of it. The other three flavours carry the same allow list, so a referrer that passes here passes there.
Three ways the rule goes wrong
Empty referrers
A request arrives with no referrer far more often than people expect. Pasting the image URL into the address bar, opening an image in a new tab, a linking page that sets Referrer-Policy: no-referrer, an HTTPS page pointing at an HTTP image (browsers suppress the referrer on a protocol downgrade), corporate proxies, most email clients, and many native apps. Block empty referrers and all of that becomes a broken image.
Current major browsers default to strict-origin-when-cross-origin, so a normal cross-origin image request does send a referrer, but only the scheme and host, never the path. Your rule can therefore identify the site that embedded you and never the page. That is fine for blocking and useless for diagnosis, which is worth knowing before you spend an hour trying to find the offending article.
Here is the honest consequence. Because you have to allow empty referrers, anyone who wants your image only has to add referrerpolicy="no-referrer" to their img tag and the request sails through. One HTML attribute defeats the entire mechanism. A referrer block stops the lazy and the automated, and it stops nobody who has read this far.
Your own infrastructure
A pull CDN fetches from your origin with no referrer, or with its own hostname. Allow none and it works. Block empty referrers and your entire image delivery goes dark within minutes of the cache expiring, which is a memorable way to spend an afternoon.
The subtler failure is caching. Your response now varies by a request header that no cache keys on by default. If an edge node stores a 403 under the image URL, every visitor gets that 403 until the entry expires. The formally correct fix, sending Vary: Referer, gives you one cache entry per distinct referrer string, which is unbounded cardinality and a hit rate near zero. There is no good version of this. It is the strongest argument for putting hotlink rules at the edge rather than behind it.
Then count your own hostnames honestly: bare domain and www, staging copies, a headless front end, the CDN’s own hostname, a translation proxy. Every one is a referrer your whitelist has to cover, and every one will be found by a customer rather than by you.
Search engines and link previews
Google Images renders its large preview by loading the file from your server, with a Google origin as the referrer, from whichever country domain the searcher used. That is what the google. pattern is for. More importantly, the crawler that put the image in the index in the first place is fetching a file directly rather than rendering a page, and such fetches commonly carry no referrer at all. A rule that blocks empty referrers can therefore remove your images from image search, quietly, over the following days. This is the one change in this article that can cost you traffic rather than save you money, so treat any advice that omits none or !^$ as broken.
The same applies to link previews. Facebook, Slack, LinkedIn, WhatsApp and the rest fetch your og:image server side, with their own user agent and no referrer. Block empty referrers and every preview your site generates goes blank at once, which reaches you as a report that the site looks broken when shared rather than as anything mentioning hotlinking.
Serving a replacement instead of blocking
Instead of a 403 you can redirect foreign requests to a different image. The version that works looks like this:
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^https?://([^.]+.)?example.com [NC]
RewriteCond %{REQUEST_URI} !/hotlink-notice.png$
RewriteRule .(jpe?g|png|gif|webp|avif)$ /wp-content/uploads/hotlink-notice.png [NC,R=302,L]
The third condition is the line most tutorials omit and the reason their version shows a broken icon. The redirect target is itself a PNG under the same rule, and the browser requests it carrying the same foreign referrer, so without that exclusion the request redirects to itself until the browser gives up. Keep the replacement small, a few kilobytes, because this path costs you a redirect plus a whole extra image, which is more bandwidth than the 403 you were trying to save.
It is also petty, and worth saying so. Sometimes it is deserved, when the referrer is a scraper farm reposting your catalogue. Sometimes it is a mistake, because the referrer belongs to a forum thread crediting you by name, and a referrer string does not tell you which. A 403 is quiet and reversible. A replacement image is a public statement made inside someone else’s page, in a context you cannot see. If you do it, make it polite, put your domain on it, and treat it as advertising rather than revenge.
Where a CDN does this better
An edge network handles this better than your origin can, for structural reasons rather than feature ones. It runs the check before your server is touched, so the bytes you save never leave your rack. It maintains verified crawler lists, so allowing search engines is not a regex you hand maintain. And it offers per IP rate limiting, which targets the actual abuse pattern, thousands of requests from one source in an hour, rather than a header anyone can remove.
It also offers the one mechanism that genuinely works: signed URLs, where the edge serves a path only when the query string carries a valid, time limited signature. A copied URL simply expires. It is also the reason nobody uses it for a public blog, because it breaks bookmarking, browser caching, RSS and anything that expects a stable address. That trade is exactly why the referrer rule survives despite being trivially bypassable.
If you already run a CDN, check its documented handling of empty referrers and crawler traffic before flipping any hotlink toggle. Defaults differ between providers, and the consequences of the wrong one land on your search visibility rather than on the hotlinker.
When the problem is reuse, not bandwidth
This is the section that matters most, and it undoes much of what came before. If what actually bothers you is other people using your photographs, hotlink protection is the wrong tool and always was. It is defeated by right click, save, upload, which is the route most people take anyway because it makes their own page faster. You will have spent an evening on server configuration and changed nothing about the thing you cared about.
The remedies for reuse are upstream, and they are decisions about what you publish rather than rules about who may fetch it.
- Publish a smaller copy. Work out the largest width your layout genuinely renders, batch resize to it, and keep the full resolution original off the public web entirely. A batch resize and convert pass over an existing library does this in one go, and it improves your page weight at the same time.
- Put attribution into the pixels. A small visible mark in a consistent corner survives copying, recompression and re-upload, which is more than any file level protection manages.
- Record ownership in the file as well, without relying on it. IPTC copyright and creator fields are the correct place for it, but generated sub-sizes and many platforms drop embedded data on processing, so treat metadata in your uploads as evidence for a dispute rather than as a deterrent.
- Enforce when it counts. Reverse image search plus a takedown notice to the host is the only remedy here with any actual force behind it.
Hotlink protection defends your bill. It does not defend your copyright, and no amount of tightening the rule will make it do so.
Symptom and cause
Your images vanished from Google Images about a week after you added the rule.
The rule blocks empty referrers, so the image crawler received a 403 and the files dropped out of the index. Allow none in nginx or !^$ in Apache, then request reindexing. Recovery takes days, not hours.
Some pages on your own site show broken images while others are fine.
A hostname mismatch. The broken pages are being served from a host your whitelist does not cover: the bare domain when you only allowed www, a staging copy, a headless front end, or the CDN’s own hostname appearing as the referrer.
Link previews went blank on Slack, LinkedIn and Facebook at the same time.
Those crawlers fetch the og:image server side with no referrer and no cookies. Same cause and same fix as the search engine case, and it is a good early warning that the rule is too strict.
Bandwidth did not drop after the block went live.
Either the hotlinked share was small next to your own traffic, which the log would have told you beforehand, or an edge cache is still serving stored responses and the rule is not being reached at all.
The replacement image shows as a broken icon or loads forever.
The redirect target matches the same rule and arrives carrying the same foreign referrer, so it redirects to itself. Add a condition excluding the replacement file’s own path.
The order that works
Log, arithmetic, rule, and only then a plugin or a hosting toggle. The log tells you whether the problem exists, because analytics structurally cannot. The arithmetic tells you whether it is worth an evening, and for most WordPress sites the honest answer is no. The rule, if you write one, belongs in the web server config, because the request never reaches PHP in the first place. Reversing that order is how people end up trading their image search traffic for a few gigabytes a month they were never really billed for.
Whatever you write, keep it narrow. Match only image extensions, only under the uploads directory, allow empty referrers, allow the search engines by name, allow every hostname you own including the ones you forgot, and return 403 rather than a redirect. A rule that blocks most hotlinking and nothing else beats a strict one that also blocks a crawler you had stopped thinking about. Then read the log again a week later, which is the only way to know whether any of it worked.
And keep the two problems apart in your head. Bandwidth is the one configuration can fix, and it is almost always smaller than the annoyance suggests. Reuse is the one that genuinely stings, and it is settled long before anyone else’s HTML points at your server, in the decision about which resolution goes onto the public internet and what mark it carries when it gets there.