Your pages load but the images do not. You see the browser’s broken image icon, an empty box with alt text, a blank space where a hero banner should be, or images that appear in the editor but not on the live site. Sometimes it only happens on one browser, on phones, or for visitors and not for you.
Every image is a separate request. When it fails, the server, a CDN or a script told the browser something specific, and that status code is the fastest route to the cause. This guide is for site owners after a migration, a domain change, a new image optimization plugin or a CDN change. If your images load but trigger a “Not secure” warning, that is mixed content and is covered in its own guide.
What is happening
WordPress stores uploads in wp-content/uploads/YYYY/MM/ and records each file as an attachment post. When it builds a page, it prints an <img> tag with a src and usually a srcset listing the resized copies it generated (thumbnail, medium, large and any theme sizes). Builders and themes may also store full image URLs directly inside page data. Any of those URLs can point at a file that no longer exists, is blocked, or is replaced by a script.
| What DevTools shows |
Meaning |
Most likely cause |
| 404 Not Found |
No file at that path |
Old domain or path after migration, missing resized copy, case mismatch, missing WebP |
| 403 Forbidden |
File exists but access refused |
Hotlink protection, file permissions, security rule, CDN firewall |
| (blocked:mixed-content) |
Browser refused an http image on an https page |
Hardcoded http URLs |
| 200 but no image visible |
File delivered, display failed |
Lazy load script not running, wrong Content-Type, corrupt file, CSS hiding it |
| No request at all |
Browser never asked for it |
Lazy load placeholder with the real URL in data-src |
Before you start
- Access: WordPress admin, plus SSH or your host’s file manager. SFTP works for checking files and permissions.
- Backup: take a database backup before any search-replace, and a copy of
wp-content/uploads before regenerating thumbnails.
- How to run commands: server commands run over SSH in the site folder, the one containing
wp-config.php. Your host may call WP-CLI wp or wp-cli; use whichever exists. curl commands run on your own computer (built into macOS and Linux; on Windows use WSL or curl.exe in PowerShell).
- Browser: open DevTools (F12), go to the Network tab, filter by “Img”, check “Disable cache”, and reload.
Step 1: Read the status code of one broken image
In the Network tab, click a red image row. The Headers pane shows the full Request URL and the Status Code. Write both down. Then check the request URL by eye: is it the right domain, the right protocol, and does the path look like /wp-content/uploads/YYYY/MM/filename.jpg?
curl -sI https://example.com/wp-content/uploads/YYYY/MM/FILENAME.jpg
Run this on your computer. Replace the URL with the broken image URL you copied. -s hides progress and -I asks for headers only. Good output starts with HTTP/2 200 and includes content-type: image/jpeg (or the matching type). Bad output is HTTP/2 404 or HTTP/2 403. Also look at the server header: cloudflare means the CDN answered, while Apache, nginx or LiteSpeed means your origin did. That tells you where to look next.
Step 2: Fix 404s after a migration or domain change
Confirm it is a URL problem
If the broken URL contains an old domain, a staging host, a temporary URL, or a folder path that does not exist on the new server, the image references were never updated. Check whether the file actually exists on the server:
ls -la wp-content/uploads/YYYY/MM/ | grep -i FILENAME
Run over SSH in the site folder. Replace YYYY/MM with the year and month folders from the image URL and FILENAME with part of the file name. -la lists all files with permissions and owners; grep -i matches without caring about case. Good output lists the file, which means only the URL is wrong. No output means the file is missing and must be copied from the old server or a backup.
Replace old URLs in the database
wp search-replace 'https://OLD_DOMAIN' 'https://example.com' --all-tables --skip-columns=guid --dry-run --report-changed-only
Run over SSH in the site folder. OLD_DOMAIN is the domain or staging host shown in the broken URLs, and example.com is your live domain, both without a trailing slash. --all-tables includes tables from plugins that do not use the standard prefix. --skip-columns=guid leaves the post GUID column alone, as WordPress recommends. --dry-run only counts and changes nothing. --report-changed-only hides tables with zero matches. Good output is a table of tables and columns with replacement counts, for example many matches in wp_posts.post_content and wp_postmeta.meta_value. When the counts look right, run the same command without --dry-run. WP-CLI handles serialized data correctly, which a plain SQL replace does not.
Some builders keep their own CSS or data caches. After the replace, regenerate them: Elementor has Tools, then Regenerate CSS and Data, and most other builders have an equivalent. Purge every page cache too.
Check the upload_path and upload_url_path options
Older sites, and sites moved from multisite or certain hosts, sometimes have custom upload locations saved in the options table. If set wrongly, WordPress writes or links to a folder that does not exist on the new server.
wp option get upload_path
wp option get upload_url_path
Run over SSH in the site folder. Good output for a normal site is empty for both, which means WordPress uses the default wp-content/uploads. Bad output is an absolute path from the old server such as /home/OLDUSER/public_html/wp-content/uploads, or an old URL. If you see one and you did not deliberately move uploads, clear them with wp option update upload_path '' and wp option update upload_url_path ''. Also check wp-config.php for an UPLOADS constant, which overrides the default folder.
Check hardcoded URLs in theme files and CSS
Background images set in a child theme stylesheet, customizer CSS or a builder global style are not touched by a database replace if they live in files. Search for them:
grep -rn "OLD_DOMAIN" wp-content/themes/ wp-content/uploads/*.css 2>/dev/null
Run over SSH in the site folder. OLD_DOMAIN is the old host name. -r searches folders recursively and -n prints line numbers. 2>/dev/null hides “no such file” messages. Any match is a file you need to edit. No output means files are clean.
Step 3: Fix case-sensitive filename mismatches
Windows and macOS file systems usually treat Hero.JPG and hero.jpg as the same file. Linux servers do not. Sites developed locally, or uploads copied from a Windows machine by FTP, can end up with a database pointing at Hero.jpg while the disk holds hero.JPG.
To confirm, run the ls | grep -i command from Step 2. If the file exists but with different capitalization from the URL, this is your cause. Fix it by renaming the file on disk to match the URL exactly, or by re-uploading through the Media Library. For many files, a rename plugin that also updates references is safer than renaming by hand.
Step 4: Fix missing resized copies and broken srcset
Confirm
The original image loads when you open its plain URL, but the page requests a sized version such as FILENAME-1024x683.jpg that returns 404. This happens when only originals were migrated, when a theme with new image sizes was activated, or when an optimization plugin deleted copies. Browsers pick from srcset by screen width, which is why it may only break on phones or only on large screens.
Regenerate
wp media regenerate --only-missing --yes
Run over SSH in the site folder. --only-missing creates only the sizes that do not exist yet, which is much faster and leaves existing files alone. --yes skips the confirmation prompt. You can pass one or more attachment IDs (find them in the Media Library URL as item=ATTACHMENT_ID) to test on a single image first: wp media regenerate ATTACHMENT_ID --only-missing. Good output ends with a line like Success: Regenerated N of N images. Bad output includes warnings such as “Can’t find” for an attachment, which means the original file itself is missing. On large libraries, run it during a quiet period because it is CPU heavy.
If images regenerate but still fail, check that the server has an image library. wp media image-size lists registered sizes, and Tools, then Site Health, then Info, then Media Handling shows whether GD or Imagick is available.
Step 5: Fix 403s from permissions, hotlink rules or security tools
File permissions and ownership
ls -la wp-content/uploads/YYYY/MM/ | head -n 10
Run over SSH. Good output shows folders as drwxr-xr-x (755) and files as -rw-r--r-- (644), owned by your site user. Bad output shows files as -rw------- (600), which the web server cannot read, or files owned by root or another user after a restore. To reset:
find wp-content/uploads -type d -exec chmod 755 {} \;
find wp-content/uploads -type f -exec chmod 644 {} \;
Run over SSH in the site folder, as the site user, not root. -type d selects folders and -type f selects files; -exec chmod ... {} \; applies the mode to each one. No output means success. If you get “Operation not permitted”, the files belong to another user and you need to open a ticket so your host can fix ownership.
Hotlink protection
Hotlink protection blocks image requests whose Referer header is not your own domain. After a domain change it may still allow only the old domain, so your own pages are blocked. Look in .htaccess for lines like RewriteCond %{HTTP_REFERER} followed by a rule ending in [F], in your hosting panel for a “Hotlink Protection” setting, and in Cloudflare under Scrape Shield or Security settings. To confirm, compare these two requests from your computer:
curl -sI https://example.com/wp-content/uploads/YYYY/MM/FILENAME.jpg
curl -sI -e https://example.com/ https://example.com/wp-content/uploads/YYYY/MM/FILENAME.jpg
-e sets the Referer header. If the first returns 200 and a request with your new domain as referer returns 403, or the reverse, hotlink rules are the cause. Add your current domain (and www and non www variants) to the allowed list.
Security plugins and WAF rules
Some security plugins and server rules block direct access to certain file types in uploads to stop malware execution. A rule meant for .php can accidentally match .webp or .svg. If only one file type fails with 403, check the security plugin’s firewall log and wp-content/uploads/.htaccess. For a deeper walkthrough of reading 403s, see the 403 Forbidden guide.
Step 6: Fix WebP, AVIF and image CDN problems
Image optimization plugins serve modern formats in one of three ways, and each fails differently:
| Method |
How it works |
How it breaks |
<picture> tags |
HTML lists a WebP or AVIF <source> and a JPEG fallback |
If the WebP file was never generated, the browser picks it anyway and shows a broken image. It does not fall back on a 404. |
| Server rewrite |
.htaccess or Nginx serves FILENAME.jpg.webp when the browser’s Accept header includes image/webp |
Rules do nothing on Nginx without config, or a CDN caches the WebP and serves it to everyone unless the response varies by Accept. |
| Image CDN |
URLs are rewritten to a CDN host that converts on the fly |
Wrong CDN hostname, expired account, or the CDN cannot fetch from your origin (firewall, basic auth, staging lock). |
To confirm, look at the failing request URL. A .webp or .avif extension, or a different host name than your site, points to this step. Disable the plugin’s WebP delivery option, purge every cache, and reload. If images return, regenerate the WebP copies in the plugin (most have a “bulk optimize” or “generate missing” button) before re-enabling delivery. For an image CDN, open the CDN URL directly; the error it returns usually names the problem.
All current major browsers support WebP, and AVIF support is widespread in current versions, but older Safari and old embedded browsers may lack AVIF. If complaints come from one device type, check that browser version.
Step 7: Fix lazy load scripts that never swap the image
Lazy loading plugins print a placeholder in src and put the real URL in data-src or data-lazy-src. A script swaps them as the image scrolls into view. If that script is delayed, deferred, blocked by an error, or removed by another optimization, the placeholder stays forever.
To confirm, inspect the broken image in the Elements panel. A data-src attribute holding the real URL and a tiny placeholder or SVG in src means the swap never happened. Check the Console for errors. Then:
- Use only one lazy loader. WordPress adds native
loading="lazy" by itself; a speed plugin, the theme and an image plugin may all add their own on top.
- Exclude the lazy load script from Delay JavaScript, or exclude above the fold images from lazy loading. Hero images should never be lazy loaded.
- If a JavaScript optimization broke the loader, the delay JS guide covers how to find and exclude the script.
Step 8: Mixed content and Cloudflare image features
If DevTools shows (blocked:mixed-content) instead of a status code, the image URL starts with http:// on an https:// page; follow the mixed content guide to fix those URLs at the source.
Cloudflare Polish (on paid plans) recompresses images and can convert them to WebP; responses it touched carry a cf-polished header. If an image looks wrong or fails only through Cloudflare, turn Polish off in the dashboard’s image optimization settings (at the time of writing, Speed, Settings, Image Optimization), purge the cache, and compare. Mirage, Cloudflare’s older mobile image feature, was deprecated on 15 September 2025; if an old configuration or page rule still references it, remove it.
Advanced: Tracing an image request end to end
When nothing obvious stands out, follow one image through every layer.
curl -sI -H "Accept: image/avif,image/webp,*/*" https://example.com/wp-content/uploads/YYYY/MM/FILENAME.jpg
curl -sI --resolve example.com:443:ORIGIN_IP https://example.com/wp-content/uploads/YYYY/MM/FILENAME.jpg
Run both on your computer. The first sends the Accept header a modern browser sends, so you see what a WebP capable visitor gets: check that content-type matches the actual bytes and that a vary: Accept header is present if the server swaps formats. The second uses --resolve to connect straight to your server, bypassing the CDN; ORIGIN_IP is your server’s IP address from your hosting panel. If the origin returns 200 but the CDN returns 403 or 404, the problem is in the CDN layer (cache, firewall or image service). If the origin also fails, the problem is on the server.
Then check the database record for the image:
wp post meta get ATTACHMENT_ID _wp_attached_file
wp post meta get ATTACHMENT_ID _wp_attachment_metadata --format=json
Run over SSH. ATTACHMENT_ID is the number in the Media Library edit URL (post.php?post=ATTACHMENT_ID). The first command prints the stored relative path, such as YYYY/MM/FILENAME.jpg; it should match the file on disk exactly, including case. The second prints the list of generated sizes and their filenames. A size listed there but missing on disk is what produces srcset 404s; regenerating fixes it. An absolute path from another server in _wp_attached_file is a migration error that search-replace will not catch; fix the value with wp post meta update.
When to contact your host
Open a ticket if files are owned by the wrong user, if images return 403 with no matching rule in your .htaccess or security plugin, or if the origin itself returns errors for files you can see on disk. Send:
- Two or three full broken image URLs and their status codes.
- The UTC time of your test and the full response headers from
curl -sI.
- Whether the origin (via
--resolve) and the CDN give different results, with any Cloudflare Ray ID.
- What changed: migration date, new plugin, CDN change or domain change.
If the whole site is failing rather than just images, start at the Emergency Room. The diagnostic tools can fetch headers for you if you cannot run curl.