719-286-0751 [email protected]

Fixing the “Admin CAPTCHA image 404s under S3/CDN remote media storage” Error in Magento 2

One of the more maddening Magento 2 problems hits the moment you move media to S3 with remote storage: a login or forgot-password form loads, but the CAPTCHA box is broken. Open the network tab and you'll find GET https://media.example.com/media/captcha/admin/<hash>.png 404 (Not Found). It isn't a PHP exception — it's a plain HTTP 404 on a static image, so nothing lands in your logs. The business impact is direct: nobody can pass a CAPTCHA they can't see, and if it's the admin form, you're locked out of your own back office.

Need help fixing your Magento 2 store?
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →

This is a confirmed stock Magento defect (upstream magento/magento2#33051), and it's area-agnostic — the original report is actually a storefront forgot-password form, not admin. We'll cover why it happens, how to confirm it, and two production-safe fixes scoped to the two topologies you'll actually meet.

Why Magento 2 throws this error

The culprit is a split-brain inside one core class, Magento\Captcha\Helper\Data. Its getImgDir() opens the media directory with the explicit local filesystem driver and writes the freshly generated PNG to local pub/media/captcha/<websiteCode>/ only:

// Magento\Captcha\Helper\Data::getImgDir()
$this->_filesystem
    ->getDirectoryWrite(DirectoryList::MEDIA, Filesystem\DriverPool::FILE)
    // ...captcha/<websiteCode>/

That DriverPool::FILE is deliberate — CAPTCHA images are single-use and intentionally never written to remote storage. But getImgUrl() builds the <img> src from the media base URL:

// Magento\Captcha\Helper\Data::getImgUrl()
return $this->_urlBuilder->getBaseUrl(['_type' => DirectoryList::MEDIA])
    . 'captcha' . '/' . $this->_getWebsiteCode($website) . '/';

(Note: stock passes DirectoryList::MEDIA, not UrlInterface::URL_TYPE_MEDIA. Both constants resolve to the string 'media', so the resulting URL is identical — but if you grep the core file, look for DirectoryList::MEDIA.)

The key correction to a common misreading: enabling remote storage does not change getBaseUrl(MEDIA). That URL is driven solely by web/*/base_media_url config (or the web-base fallback) and never consults the remote-storage driver. Remote storage changes the filesystem driver — where bytes are read and written — not the URL contract. The real desync is that normal media now lives on S3, but CAPTCHA pins the local FILE driver, so the PNG lands on local disk while whatever serves base_media_url looks for it elsewhere.

  • The CAPTCHA PNG is written to local disk by design; getImgDir() hard-pins DriverPool::FILE because the images are single-use.
  • Normal media is now read/written through the S3 driver, so whatever serves base_media_url (an Nginx /media/ → S3 proxy, or a separate CDN host) doesn't have the locally written PNG.
  • No sync runs between generation and display — the browser requests the image milliseconds after it's written, so even periodic media sync wouldn't help.
  • The result depends on your topology, and there are two: media on the app domain proxied to S3, or media on a genuinely separate host.

1. Confirm remote storage is enabled

Remote storage lives in app/etc/env.php, not in core_config_data. Check it directly.

grep -n "'remote_storage'" -A6 app/etc/env.php

You should see a remote_storage block with 'driver' => 'aws-s3' (or another remote driver). That confirms normal media is going through the S3 driver while CAPTCHA stays local — the precondition for this bug. If there is no remote_storage block, this article doesn't apply.

2. Identify your media topology

The fix you need depends on where base_media_url points relative to base_url. Read both from config.

SELECT path, value FROM core_config_data
WHERE path IN ('web/secure/base_url','web/unsecure/base_url',
               'web/secure/base_media_url','web/unsecure/base_media_url');

Interpret it like this:

  • Same host (e.g. base_media_url is https://your-store.example.com/media/ and base_url is https://your-store.example.com/, or base_media_url is empty so it falls back to the web base). This is the canonical documented S3 setup: Nginx serves the app domain and proxies /media/ image requests to S3. The 404 comes from that proxy. Go to Section 3.
  • Different host (e.g. base_media_url is https://media.example.com/media/ while base_url is https://your-store.example.com/). Media is served from a separate host that never received the local PNG. Go to Section 4.

Do not use the host comparison as a yes/no test for the bug itself — the 404 happens in both topologies. It only tells you which fix applies.

3. Confirm the PNG is local-only, then serve /media/captcha/ from disk

First prove the mismatch: the file exists locally but not in the bucket.

# Newest locally generated CAPTCHA images:
ls -la pub/media/captcha/*/ | tail -5

# Confirm it is NOT in the bucket (adjust profile/bucket/prefix):
aws s3 ls s3://your-bucket/media/captcha/ --recursive | tail -5

You should see recent .png files under local pub/media/captcha/<websiteCode>/ and an empty (or stale) listing in S3. That proves the local write is in effect while the served URL resolves to the S3-backed proxy.

In the same-host topology, the correct fix is at the web tier: serve /media/captcha/ from local disk before falling back to the S3 proxy. Add a more specific location ahead of your existing /media/ proxy block:

# Serve single-use CAPTCHA images from local disk; everything else /media/ proxies to S3.
location ^~ /media/captcha/ {
    root $MAGE_ROOT/pub;
    try_files $uri =404;
}

Reload Nginx (nginx -t && nginx -s reload) and refresh the form. The CAPTCHA request now returns 200 from local disk, while the rest of /media/ keeps proxying to S3. This is the right fix for any standard stock S3 store, where a URL rewrite alone would do nothing (see Section 4 for why).

Need help fixing your Magento 2 store?
If this error is impacting live revenue, our senior Magento team can help you debug and deploy a fix safely.
Contact us →

4. Separate media host: rewrite the CAPTCHA URL to the app host

This section applies only when base_media_url is on a host distinct from base_url. Here the plugin approach works, because rewriting from the media host to the app host actually changes the URL.

A precondition first: the app/web host you rewrite to must serve /media/captcha/ from local disk (the Nginx location ^~ /media/captcha/ from Section 3, or a /media/ location that does try_files against local files before any S3 proxy). If that host also proxies /media/ straight to S3, the rewrite still 404s. Verify that vhost's config before relying on this fix.

Build a tiny module with one after plugin. Register Vendor_MediaUrlFix (registration.php + etc/module.xml with <sequence><module name="Magento_Captcha"/></sequence>), then add the plugin:

<?php
declare(strict_types=1);
namespace Vendor\MediaUrlFix\Plugin;

use Magento\Captcha\Helper\Data;
use Magento\Framework\UrlInterface;
use Magento\Store\Model\StoreManagerInterface;

class CaptchaUrlRewritePlugin
{
    public function __construct(private readonly StoreManagerInterface $storeManager) {}

    public function afterGetImgUrl(Data $subject, string $result): string
    {
        if (strpos($result, '/captcha/') === false) {
            return $result;
        }
        $store = $this->storeManager->getStore();
        // URL_TYPE_MEDIA and DirectoryList::MEDIA both resolve to 'media', so this matches the value getImgUrl() used.
        $mediaBaseUrl = $store->getBaseUrl(UrlInterface::URL_TYPE_MEDIA);
        $appMediaUrl  = $store->getBaseUrl(UrlInterface::URL_TYPE_WEB) . 'media/';

        // Only rewrite when media is on a different host; otherwise leave it alone.
        if ($mediaBaseUrl === $appMediaUrl) {
            return $result;
        }
        // The media base URL appears once, as the leading prefix; a prefix-anchored
        // replace avoids any chance of over-replacing.
        return preg_replace('#^' . preg_quote($mediaBaseUrl, '#') . '#', $appMediaUrl, $result);
    }
}

Wire it under etc/di.xml (global) so it covers admin and storefront CAPTCHA forms alike — the core defect is area-agnostic:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Captcha\Helper\Data">
        <plugin name="vendor_mediaurlfix_captcha_local_img_url"
                type="Vendor\MediaUrlFix\Plugin\CaptchaUrlRewritePlugin"/>
    </type>
</config>

getImgUrl() now returns https://your-store.example.com/media/captcha/<websiteCode>/<hash>.png — the app host, where (per the precondition above) Nginx serves the file from local pub/media/.

5. Enable the module and rebuild config

bin/magento module:enable Vendor_MediaUrlFix \
  && bin/magento setup:upgrade \
  && bin/magento cache:clean config full_page
bin/magento module:status Vendor_MediaUrlFix

That should report the module as enabled. Reload the form: the CAPTCHA image now returns 200. If you used the Section 3 Nginx fix instead, there's no module to enable — just confirm the location ^~ /media/captcha/ block is in effect and reload.

When this points to a deeper problem

This 404 is a symptom of a broader class issue, not a one-off.

  • Some media (CAPTCHA, single-use temporary images) is intentionally local-only via DriverPool::FILE, but the rest of /media/ is read and written through the remote-storage driver — so a single config flip splits where the bytes live from where the URL resolves.
  • Enabling remote storage changes the filesystem driver, not the URL-generation contract. Latent local-only writers only surface once their files stop existing wherever base_media_url is served from.
  • The write driver and the served URL are decided in the same class but never cross-checked, so the desync produces no compile-time or runtime signal — just a missing file.
  • The failure is an HTTP 404 on a static asset — it bypasses PHP error logging entirely.

We've seen this most often when a store migrates media to S3 long after launch and nobody re-audits the single-use asset paths. Audit every consumer of the media base URL against where the asset is actually written — grep the codebase for local-driver writes into the media directory:

grep -rnE 'DriverPool::FILE|DriverInterface::WRITER' --include='*.php' vendor/magento app/code | grep -i media

Cron-generated reports and temporary exports are prime suspects for the same mismatch. Patching the URL (or the Nginx location) gets people logged back in; a real audit keeps the next remote-storage surprise off your network tab.

You now have a confirmed diagnosis, a topology check that tells you which fix to apply, and two production-safe fixes — an Nginx location for the canonical S3 setup and an area-agnostic plugin for the separate-media-host setup — that resolve the 404 without touching vendor code. Pick the one that matches your config, flush, and your users log in cleanly again.

Install our webapp on your iPhone! Tap and then Add to homescreen.
Share This