In Magento 2 you encrypt a value with Magento\Framework\Encryption\EncryptorInterface::encrypt(), make it URL-safe with Magento\Framework\Url\EncoderInterface::encode(), and reverse both in your controller with DecoderInterface::decode() and decrypt(). Encryption hides the path. It doesn't restrict who can open the link, so you still check the customer session.
This question originally came up on Campus: a store wanted to hand customers a private PDF without a link like https://example.com/media/pdf/my-secret-file.pdf telling everyone where the private files live. Encrypting the path in the URL hides it, and it takes about three classes in Magento. Getting the access control right takes a few more lines, and that's the part most examples skip.
Encryption vs encoding in Magento
Magento ships two pieces you'll use together:
EncryptorInterfaceencrypts and decrypts a string with the store's crypt key fromapp/etc/env.php. On current versions this is authenticated encryption (Sodium), so a tampered value doesn't decrypt to something "close": it comes back as an empty string.EncoderInterface(Magento\Framework\Url\Encoder) is URL-safe base64. Its whole implementation isstrtr(base64_encode($url), '+/=', '-_~').
Encryption makes the path unreadable; URL-safe base64 encoding just makes the ciphertext survive a query string. The encoder adds zero secrecy on its own. Anyone can base64-decode a string.
Is URL encryption enough to protect a file?
No. An encrypted URL is still a URL. If a customer forwards the email, or the link lands in browser history or a Referer header, whoever has it can open it. So a real "private download" needs three things:
- Keep the files out of the web root. Anything in
pub/mediais served directly by the web server, so someone who guesses/media/pdf/my-secret-file.pdfskips your controller entirely. Put private files invar/secret/instead, which is never web-accessible. - Check who's asking. The controller requires a logged-in customer, and the encrypted payload carries the customer ID it was generated for, so a forwarded link is useless to anyone else.
- Give the link an expiry. An
expirestimestamp inside the encrypted payload means an old link stops working on its own.
The customer then sees a URL like https://example.com/secret?f=MDozOi9MNGF0amlkT0MrbXkwVURqbEo5NUFIV2dLRkdTZ1diTzZ4T0JXaTBQblUyb3RndUpMcGgzQlpXMjNvRktRPT0~. The path, the customer ID and the expiry are all inside that ciphertext, and only your store's key can read them.
If the file is actually a product a customer buys, use what core already has: Magento_Downloadable handles "only buyers can download this" with link hashes plus a customer and purchase check (see Magento\Downloadable\Controller\Download\Link). The approach below is for one-off private files that aren't products.
Let's build it.
Create the encrypted link to the file
The link gets generated wherever you render it for the customer. That could be a ViewModel, a model or a controller in your module. Here it's a ViewModel (see block classes vs view models for why that's usually the right home for template logic):
<?php
declare(strict_types=1);
namespace Macademy\SecretUrl\ViewModel;
use Magento\Customer\Model\Session;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\Url\EncoderInterface;
use Magento\Framework\UrlInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class Url implements ArgumentInterface
{
private const LINK_LIFETIME = 3600;
public function __construct(
private readonly EncryptorInterface $encryptor,
private readonly EncoderInterface $urlEncoder,
private readonly UrlInterface $url,
private readonly Session $customerSession,
private readonly Json $serializer,
) {}
public function getSecretUrl(string $filename): string
{
return $this->url->getUrl('secret', [
'_query' => ['f' => $this->getSecretPath($filename)],
]);
}
private function getSecretPath(string $filename): string
{
$payload = $this->serializer->serialize([
'file' => $filename,
'customer_id' => (int) $this->customerSession->getCustomerId(),
'expires' => time() + self::LINK_LIFETIME,
]);
return $this->urlEncoder->encode($this->encryptor->encrypt($payload));
}
}getSecretPath() builds the payload, encrypts it, and makes it URL-safe. getSecretUrl() wraps that in the full URL. getSecretPath() is only called from getSecretUrl(), so it's private.
Notice the URL is built with the _query route param. Calling $this->url->setQueryParam('f', ...)->getUrl('secret') would also work, but it mutates the shared UrlInterface instance, so that query param can leak into later getUrl() calls in the same request.
Display the secret link on a page
To keep things simple, the link shows up on the My Account dashboard, which only logged-in customers can reach. Here's the template:
<?php
/** @var Magento\Framework\View\Element\Template $block */
/** @var Magento\Framework\Escaper $escaper */
/** @var Macademy\SecretUrl\ViewModel\Url $urlViewModel */
$urlViewModel = $block->getData('url_view_model');
$filename = 'my-secret-file.pdf';
$secretUrl = $urlViewModel->getSecretUrl($filename);
?>
<div class="block block-encrypted-encoded-url">
<div class="block-title">
<strong><?= $escaper->escapeHtml(__('Your Special URL')) ?></strong>
</div>
<div class="block-content">
<p><a href="<?= $escaper->escapeUrl($secretUrl) ?>" target="_blank"><?= $escaper->escapeHtml($filename) ?></a></p>
</div>
</div>The filename is hard-coded here. Your real code will probably look it up based on the customer or the order.
Then add the block to the My Account dashboard with layout XML. The link is unique per customer, so the block is marked cacheable="false" (the dashboard isn't full-page cached anyway):
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block name="encrypted.encoded.url" template="Macademy_SecretUrl::encrypted-encoded-url.phtml" cacheable="false">
<arguments>
<argument name="url_view_model" xsi:type="object">Macademy\SecretUrl\ViewModel\Url</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>The dashboard now shows the filename, and hovering the link shows the encrypted URL in the status bar:
Create the route and controller
Clicking the link returns a 404 for now, since nothing listens on /secret. Register the route first:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="secret" frontName="secret">
<module name="Macademy_SecretUrl"/>
</route>
</router>
</config>(If routing in Magento still feels like magic, the advanced routing and custom routers course walks through how a front name finds its controller.)
The controller does the reverse of the ViewModel, in reverse order: decode, then decrypt. Then it checks everything before it touches the filesystem:
<?php
declare(strict_types=1);
namespace Macademy\SecretUrl\Controller\Index;
use InvalidArgumentException;
use Magento\Customer\Model\Session;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\Response\Http\FileFactory;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\Result\RedirectFactory;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\Exception\NotFoundException;
use Magento\Framework\Filesystem;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\Url\DecoderInterface;
class Index implements HttpGetActionInterface
{
private const SECRET_DIR = 'secret';
public function __construct(
private readonly RequestInterface $request,
private readonly Session $customerSession,
private readonly RedirectFactory $redirectFactory,
private readonly DecoderInterface $urlDecoder,
private readonly EncryptorInterface $encryptor,
private readonly Json $serializer,
private readonly Filesystem $filesystem,
private readonly FileFactory $fileFactory,
) {}
/**
* @throws NotFoundException
*/
public function execute(): ResultInterface|ResponseInterface
{
if (!$this->customerSession->isLoggedIn()) {
return $this->redirectFactory->create()->setPath('customer/account/login');
}
$payload = $this->getPayload((string) $this->request->getParam('f'));
if (
$payload === null
|| (int) $payload['customer_id'] !== (int) $this->customerSession->getCustomerId()
|| (int) $payload['expires'] < time()
) {
throw new NotFoundException(__('File not found.'));
}
// basename() keeps a decrypted value from walking out of var/secret
$filePath = self::SECRET_DIR . '/' . basename((string) $payload['file']);
if (!$this->filesystem->getDirectoryRead(DirectoryList::VAR_DIR)->isFile($filePath)) {
throw new NotFoundException(__('File not found.'));
}
return $this->fileFactory->create(
basename($filePath),
['type' => 'filename', 'value' => $filePath],
DirectoryList::VAR_DIR,
'application/pdf',
);
}
private function getPayload(string $secretPath): ?array
{
if ($secretPath === '') {
return null;
}
$decrypted = $this->encryptor->decrypt($this->urlDecoder->decode($secretPath));
if ($decrypted === '') {
return null;
}
try {
$payload = $this->serializer->unserialize($decrypted);
} catch (InvalidArgumentException) {
return null;
}
return is_array($payload) && isset($payload['file'], $payload['customer_id'], $payload['expires'])
? $payload
: null;
}
}A few details worth calling out:
- A guest gets sent to the login page, and a logged-in customer holding someone else's link (or an expired one) gets a 404. A 404 doesn't confirm the file exists, which is what you want here.
- A missing or tampered
fparam never reaches the filesystem.decrypt()returns an empty string for anything it can't authenticate, andgetPayload()bails on that. FileFactory::create()gets an array with'type' => 'filename', so it streams the file straight fromvar/secret/. Passing the file contents as a string instead looks like it works, butFileFactorythen writes that string back to<baseDir>/<fileName>before sending it, so every download rewrites the file, after loading the whole thing into memory first.
Drop a file at var/secret/my-secret-file.pdf, click the link from My Account, and the download starts, without the URL ever revealing where the file lives or letting anyone else use it.
Get the code
The full module is on GitHub:
If you want to dig further, the Adobe Commerce developer documentation is the first place to look for a specific class or method, and Magento Stack Exchange has plenty of encryption questions already answered. If any of the code above tripped you up (ViewModels, routes, DI), the Magento 2 Coding Kickstart course covers those building blocks.
Got a different way you handle private downloads on your stores? I'd love to hear how you've solved it.



