Anzen
Media URL masking for WordPress: hides real upload paths behind signed, encrypted tokens (AES-256 + HMAC). Standalone endpoint without WordPress boot, fast nginx delivery via X-Accel-Redirect, custom URL prefix, setup wizard, config stored outside the plugin folder.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/suzu-chan1990/anzen/archive/refs/heads/main.zip
🔒 Anzen – Media URL Masking for WordPress
Anzen (安全, "safety") hides the real paths of your media files. Instead of
https://example.com/wp-content/uploads/2026/11/cover.webp
visitors only see a signed, encrypted token:
https://example.com/media/MmRkMzE2NmQ0MDk3NzQxOO0BH4Cl8Ons1KX2…
The token is resolved by a small standalone endpoint (serve.php) that does not load WordPress. On nginx the file itself is then delivered by nginx (X-Accel-Redirect); on other servers by PHP.
✨ Features
- Encrypted, authenticated tokens – AES-256-GCM (new installations) or AES-256-CBC + HMAC-SHA256 (installations before 1.4.1). Tokens cannot be guessed, changed or used for other files.
- Switch to GCM – one click under Settings → Anzen Security; existing links keep working.
- Stable URLs – the same file always gets the same token, so caches and CDNs work normally.
- Fast delivery –
serve.phpruns without WordPress and without database queries.- nginx: hand-off via
X-Accel-Redirect, nginx sends the file. - Apache / LiteSpeed / other servers: PHP
readfile().
- nginx: hand-off via
- Your own URL prefix –
/media/,/files/,/fairu/… chosen in the setup. - Setup wizard – generates a 256-bit key, saves the configuration outside the plugin folder (it survives updates) and shows the matching server rule.
- Admin untouched – the media library and the dashboard keep the real URLs; only the frontend is masked.
- Hardened endpoint – timing-safe comparisons, path traversal and null-byte protection, allow-list of file types, a plain 404 for every invalid request. SVG files are sent with a restrictive Content-Security-Policy.
- Updates from GitHub – built-in update checker for new releases.
🖼️ What gets masked
Media files in the uploads folder with these extensions:
jpg jpeg png gif webp avif jxl svg mp4 webm mp3 wav ogg
Anzen hooks into:
| Source | Filter |
|---|---|
| Attachment URLs | wp_get_attachment_url, wp_get_attachment_image_src |
| Image tags and srcset | wp_get_attachment_image, wp_calculate_image_srcset |
| Featured images | get_the_post_thumbnail_url, post_thumbnail_html |
| Post content | the_content |
CSS, JavaScript and other theme or plugin files are not touched.
⚙️ Requirements
| WordPress | 7.1 or newer |
| PHP | 8.4 or newer with the openssl extension |
| Server | nginx (recommended), Apache, LiteSpeed or any server with URL rewriting |
📦 Installation
- Upload the
anzenfolder to your plugins directory and activate the plugin. - Open Settings → Anzen Security.
- Choose the URL prefix and the delivery method, then click Complete Setup & Generate Key.
- Add the server rule that the wizard shows (see below) and reload the web server.
- Reload the setup page – Anzen is active.
The wizard saves the configuration outside the plugin folder (see Configuration), so plugin updates never delete the key.
🌐 Server rules
Replace media with your prefix and the path to serve.php with the one shown by the wizard (plugin folders can have other names).
nginx
Add to the server { … } block of the site, before the general WordPress location /:
location ~ ^/media/([a-zA-Z0-9_-]+)$ {
rewrite ^/media/([a-zA-Z0-9_-]+)$ /wp-content/plugins/anzen/serve.php?t=$1 last;
}
Then check and reload the configuration:
nginx -t && systemctl reload nginx
Apache / LiteSpeed (.htaccess)
At the top of the .htaccess in the WordPress directory:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^media/([a-zA-Z0-9_-]+)$ wp-content/plugins/anzen/serve.php?t=$1 [L]
</IfModule>
🔧 Configuration
Location
WordPress deletes the whole plugin folder on every update, so Anzen keeps its configuration (with the secret key) outside of it. By default it is stored one level above the WordPress directory:
/path/to/site/ ← parent folder
├── secrets/
│ └── anzen-<site folder>.php ← Anzen configuration (0600)
└── <site folder>/ ← WordPress (wp-load.php, wp-content …)
The file name contains the name of the WordPress folder, so several sites in the same parent folder never share one configuration. The folder is outside the web root and cannot be reached from the browser.
PHP must be allowed to write there. If your hosting panel uses open_basedir, the parent folder has to be included (the default on most panels, as the customer folder is the parent of the site). Otherwise choose another location in wp-config.php, above the line /* That's all, stop editing! */:
define( 'ANZEN_CONFIG_FILE', '/path/outside/webroot/anzen-config.php' );
Upgrading from 1.3.x: the old config.php in the plugin folder is copied to the new location automatically on the first request after the update (same key, all links keep working). If that fails, a warning appears in the admin. Make sure this has happened before the next update.
Constants
| Constant | Meaning |
|---|---|
ANZEN_KEY |
Secret key (32 bytes). Never change or delete it – all existing links depend on it. |
ANZEN_TARGET_DIR |
Absolute path of the uploads folder |
ANZEN_NGINX_URI |
URL path of the uploads folder (used for X-Accel-Redirect) |
ANZEN_METHOD |
nginx or php |
ANZEN_MASK_PREFIX |
URL prefix, e.g. media |
ANZEN_CIPHER |
Format of new URLs: gcm or cbc. Missing = cbc (installations before 1.4.1). Both formats are always accepted. |
Changing the prefix later
Edit ANZEN_MASK_PREFIX in the configuration file and update the server rule. The tokens stay the same, only the folder in the URL changes. Keep the old rule for a while if old URLs are still cached.
Do not delete the configuration file to run the wizard again: a new key would be generated and every existing link would stop working.
🛡️ How it works
-
Masking – on the frontend, Anzen replaces upload URLs with
/<prefix>/<token>. -
Token – two formats, a file always gets the same token:
- GCM (default since 1.4.1):
token = "g" + base64url( nonce ‖ AES-256-GCM( path ) ‖ tag ). Encryption key and nonce key are derived fromANZEN_KEYwith HKDF; the nonce is an HMAC of the path. - CBC (installations before 1.4.1):
token = base64url( IV ‖ AES-256-CBC( path | HMAC-SHA256(path) ) ). The IV is derived from the path and the key.
serve.phprecognizes the format by the first character and accepts both. - GCM (default since 1.4.1):
-
Delivery – the server rule sends
/<prefix>/<token>toserve.php. It decrypts the token, checks the signature, the IV and the path, and delivers the file – or answers 404.
🔄 Updates
Anzen updates itself from the GitHub releases of this repository. A release is offered as an update when its tag matches a higher Version: in anzen.php (e.g. tag v1.3.1 for version 1.3.1). If a ZIP is attached to the release, that ZIP is installed; it has to contain the folder anzen/. The configuration is stored outside the plugin folder and is not touched by updates.
📜 Changelog
v1.4.1
- Fixed: AVIF and JPEG XL files (
.avif,.jxl) were not masked. Image converters like Henkan add them as<source>in<picture>tags, and browsers load AVIF first, so the real upload path stayed visible. Both types are now masked and delivered. - Fixed:
serve.phpsets the content type by file extension.mime_content_type()does not know AVIF and JPEG XL on older systems and returnedapplication/octet-stream. - Added: New token format AES-256-GCM: encryption and authentication in one step, URLs about half as long. New installations use it from the start.
- Added: Existing installations keep AES-256-CBC, so all links stay the same. Settings → Anzen Security has a button to switch new URLs to GCM; existing CBC links keep working after the switch, because
serve.phpaccepts both formats. - Added: Status page under Settings → Anzen Security after the setup (configuration file, prefix, delivery method, token format).
v1.4
- Fixed: Every plugin update deleted
config.phpand with it the secret key, so the setup had to run again and a new key broke all masked links. WordPress removes the whole plugin folder on updates. The configuration is now stored outside the plugin folder, one level above the WordPress directory:secrets/anzen-<site folder>.php(directory0700, file0600). Another location can be set withANZEN_CONFIG_FILEinwp-config.php. - Added: An existing
config.phpin the plugin folder is copied to the new location automatically (same key, all links keep working). If the location is not writable (e.g.open_basedir), the old file keeps working and the admin shows a warning. - Added: The setup wizard shows where the configuration was saved.
v1.3.2
- Fixed: Resized images (thumbnails, medium, site icon sizes …) got broken URLs like
/media/cropped-favicon-32x32.webp. WordPress builds the URL of a resized image from the full-size URL by replacing the file name; that URL was already masked, so the file name ended up after the prefix. The full-size URL now stays unmasked inside this step and the finished URL of the resized image is masked as usual.
v1.3.1
- Fixed: The prefix chosen in the setup (e.g.
example) was ignored and URLs always used/media/. The default was defined beforeconfig.phpwas loaded. Existing tokens stay valid; only the folder in the URL changes. - Fixed: The GitHub updater was only loaded in the admin. Scheduled update checks and automatic background updates (WP-Cron) did not work; it is now loaded on every request.
- Fixed: Wrong text domain in the plugin header (
hydro-shortlinks→anzen). - Changed: Requires WordPress 7.1 and PHP 8.4.
v1.3
- Added: Hexadecimal quote characters (
\x22,\x27) in all regular expressions, so they survive editing in console editors. - Added: Already masked URLs are never masked twice.
- Fixed:
serve.phpreads the token from the request URI, so it also works when proxies or FastCGI setups drop query parameters. - Fixed: Content filters only touch the supported media file types; CSS and JavaScript are never rewritten.
- Fixed: Configuration constants are only defined once (no PHP warnings).
- Fixed: Strict separation of path and signature when reading tokens.
- Changed: nginx delivery via
X-Accel-Redirect.
v1.2
- Setup wizard with server detection
- Delivery method switch (nginx ↔ PHP)
- Admin bypass and non-persistent cache group
wp_parse_url()everywhere
v1.0
- Initial release with token-based masking
🛡️ Security
Please report security issues privately – see SECURITY.md.
📄 License
GPLv2 or later.
👤 Author
Saguya – GitHub @Suzu-chan1990
💬 Support & Feedback
- Report a bug
- Request a feature
- ⭐ Star the repository if Anzen is useful to you.