Downloadable Products in Magento 2: Licenses, Link Security, Delivery
AI generated
M2
di.xml
Magento 2 · Downloadable Products · Security · PHP 8.4
Downloadable Products in Magento 2
Licenses, Link Security and Secure Delivery

Anyone selling software licenses, e-books or whitepapers through downloadable products in Magento 2 must secure far more than just the checkout: link_url_key hashing, download limits, order-status gating and a delivery mechanism that never exposes the raw file path decide whether digital goods stay protected or end up freely copyable across the internet.

18 min read Data Model · Link Security · Signed URLs · License Pool Magento 2.4.8-p4 · PHP 8.4 · Hyva

1. When downloadable products in Magento 2 make sense

A downloadable product is the dedicated product type in Magento 2 for digital goods delivered as a file after purchase: software licenses, e-books, music files, whitepapers or templates. Unlike a virtual product, which represents purely a service without physical or digital delivery, the downloadable product transports actual files through its own download infrastructure with access control. Unlike a simple product, shipping and stock become irrelevant, but an entirely separate class of risk appears instead: once someone has access to a file, they can copy and redistribute it as often as they like unless the shop erects technical barriers.

The business model behind downloadable products is usually license-based rather than unit-based: the customer does not buy the file itself but a usage right, restricted through a license key, a limited number of downloads, or a time-bound validity. This clearly distinguishes commercial use from the naive use case of simply hanging a file behind the checkout. Anyone selling software licenses or paid e-books must design the technical implementation from the start around control rather than mere availability.

For agencies this means: the default configuration of Magento 2 for downloadable products covers the basics, link storage, sample management, download counters, but is not sufficient for sensitive digital goods. Anyone selling software licenses with activation keys or delivering copyrighted material must additionally implement the protection mechanisms described in this article: secure link generation, consistent order-status gating, and a delivery mechanism that never exposes the raw file path in the browser.

2. Data model: downloadable_link, downloadable_sample, downloadable_link_purchased

Magento 2 registers downloadable products through the product type downloadable in Magento\Downloadable\Model\Product\Type, which, alongside the standard EAV attributes from catalog_product_entity, brings two dedicated table families: one for links, one for free samples. The table downloadable_link stores the actual purchasable resources, every row references a product via product_id and contains columns such as link_url, link_file, link_type, number_of_downloads and link_url_key. The table downloadable_sample is structurally almost identical but represents preview files that are publicly accessible without purchase, for example a sample chapter of an e-book.

As soon as an order containing downloadable products is placed, a row is created in downloadable_link_purchased, linked to the associated sales_order_item via order_item_id and to the order record via order_id. This table carries fields such as link_hash, number_of_downloads_bought and status, representing the purchase context, not the product itself. Only the child table downloadable_link_purchased_item links the purchase to the concrete link from downloadable_link via link_id and maintains its own counter number_of_downloads_used per item.

This three-tier separation, product definition, order context, usage counter, is not accidental, it allows the same link to be tracked independently across multiple orders. Two customers who buy the same downloadable product receive different link_hash values in downloadable_link_purchased, even though both point to the same row in downloadable_link. This isolates abuse per order, so that a compromised link never automatically affects all buyers.

The core of link security for downloadable products is that the real file location must never become visible in the frontend. Magento generates, for every link via Magento\Downloadable\Helper\File::generateKey(), a random, cryptographically sufficiently long hash, stored as link_url_key or, after purchase, as link_hash in downloadable_link_purchased. The download controller resolves this hash against the real file server-side, the customer sees only the hash URL in the browser, never the path below var/downloadable/files/ or an externally hosted plain-text link.

Magento distinguishes two link_type values with different security implications: file stores the file within the Magento file storage under pub/media/downloadable/files/, so that the complete access protection runs through the controller. url instead points to an external resource, which can be practical for CDN-hosted assets but carries a risk: if the external URL is unprotected, it can be called directly regardless of Magento's access protection. For license-bound software downloads, link_type = file with server-side controlled access is therefore almost always the right choice.

A common mistake in custom implementations is leaving the path below pub/media/downloadable directly reachable through webserver configuration, for example because a reverse proxy or a CDN rule publicly caches the entire media path. The link_url_key mechanism only provides security if the physical file path itself is never directly callable, but remains reachable exclusively through the controlled download controller. A webserver deny rule on the downloadable/files directory therefore belongs in every production setup with downloadable products.

4. Access control: order-status gating and download limits

Magento only releases a download for downloadable products once the associated order has reached a certain status, configurable by default via Magento_Downloadable/etc/config.xml with the option "Order Item Status to Enable Downloads" (Pending or Invoiced). Only once an invoice exists or the order is marked complete does the status of the associated Purchased\Item record change and the download link get unlocked in the customer account. Before that point, the controller consistently returns an error, even if an attacker had guessed the correct hash.

The second protection layer is number_of_downloads, the per-link configurable limit on how many times a file may be downloaded, with the special value 0 for unlimited. Every successful download increments number_of_downloads_used in downloadable_link_purchased_item, and the controller checks before every delivery whether this counter has already reached the limit. For software licenses with limited activations, a low, explicit limit makes more sense than the unlimited default, because it technically hinders redistribution of downloads beyond the shop's own customer base.

Anyone wanting to add their own checks before delivery, for example an additional IP restriction or coupling to a license pool, should not patch the core controller but should insert a custom Service Contract that reads the status from Magento\Downloadable\Model\Link\Purchased\Item before any file access takes place.


declare(strict_types=1);

namespace Mironsoft\DownloadGuard\Model;

use Magento\Downloadable\Model\Link\Purchased\Item;
use Magento\Downloadable\Model\Link\PurchasedFactory;
use Magento\Downloadable\Model\ResourceModel\Link\Purchased\Item\CollectionFactory;
use Magento\Framework\Exception\LocalizedException;

/**
 * Service Contract that validates access to a purchased downloadable link
 * before the delivery controller is allowed to stream any file.
 */
class DownloadAccessValidator
{
    /**
     * @param CollectionFactory $itemCollectionFactory Factory for purchased-item collections
     * @param PurchasedFactory $purchasedFactory Factory for the purchased-link aggregate
     */
    public function __construct(
        private readonly CollectionFactory $itemCollectionFactory,
        private readonly PurchasedFactory $purchasedFactory,
    ) {
    }

    /**
     * Validates that the given link hash may currently be downloaded.
     *
     * @param string $linkHash The public, non-guessable hash from the request URL
     * @return Item The validated purchased-item entity
     * @throws LocalizedException If the order status forbids access or the limit is reached
     */
    public function validate(string $linkHash): Item
    {
        $collection = $this->itemCollectionFactory->create();
        $collection->addFieldToFilter('link_hash', ['eq' => $linkHash]);
        /** @var Item|null $item */
        $item = $collection->getFirstItem();

        if (!$item || !$item->getId()) {
            throw new LocalizedException(__('Invalid download link.'));
        }

        if ($item->getStatus() !== Item::LINK_STATUS_AVAILABLE) {
            throw new LocalizedException(__('This download is not available yet.'));
        }

        $limit = (int) $item->getNumberOfDownloadsBought();
        $used = (int) $item->getNumberOfDownloadsUsed();

        if ($limit > 0 && $used >= $limit) {
            throw new LocalizedException(__('Download limit reached for this item.'));
        }

        return $item;
    }
}

5. Creating downloadable products programmatically

For data migrations, import scripts or setup patches that create downloadable products automatically, ProductRepositoryInterface and Magento\Downloadable\Api\Data\LinkInterfaceFactory are the right Service Contracts. Instead of instantiating the product model directly, one works exclusively through the repository layer, so that indexer invalidation, event dispatching and plugin chains take effect correctly, exactly as with any other product type.

The product type must be explicitly set to downloadable, and the links are not saved directly but passed via the downloadable_link_data extension attribute on the product before save() is called. This separation between base product data and type-specific downloadable data mirrors exactly the data model from section 2: first the product is persisted in catalog_product_entity, then a plugin at the repository level processes the extension attributes and writes the associated rows into downloadable_link.


declare(strict_types=1);

namespace Mironsoft\DownloadableImport\Model;

use Magento\Catalog\Api\Data\ProductInterfaceFactory;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Product\Type as ProductType;
use Magento\Catalog\Model\Product\Visibility;
use Magento\Downloadable\Api\Data\LinkInterfaceFactory;
use Magento\Downloadable\Api\Data\ProductAttributeInterfaceFactory;
use Magento\Framework\Exception\CouldNotSaveException;

/**
 * Creates a downloadable product with one purchasable link via Service Contracts.
 */
class LicenseProductCreator
{
    /**
     * @param ProductInterfaceFactory $productFactory Factory for the base product entity
     * @param ProductRepositoryInterface $productRepository Repository used to persist products
     * @param LinkInterfaceFactory $linkFactory Factory for downloadable link data objects
     * @param ProductAttributeInterfaceFactory $extensionFactory Factory for downloadable extension attributes
     */
    public function __construct(
        private readonly ProductInterfaceFactory $productFactory,
        private readonly ProductRepositoryInterface $productRepository,
        private readonly LinkInterfaceFactory $linkFactory,
        private readonly ProductAttributeInterfaceFactory $extensionFactory,
    ) {
    }

    /**
     * Creates a downloadable license product with the given SKU and file path.
     *
     * @param string $sku Unique product SKU
     * @param string $name Product name shown in the storefront
     * @param string $filePath Absolute path to the license file on disk
     * @return int The number of downloads granted per purchase
     * @throws CouldNotSaveException If the product or its links cannot be persisted
     */
    public function create(string $sku, string $name, string $filePath, int $downloadsAllowed = 3): int
    {
        $product = $this->productFactory->create();
        $product->setTypeId(ProductType::TYPE_DOWNLOADABLE)
            ->setAttributeSetId(4)
            ->setSku($sku)
            ->setName($name)
            ->setVisibility(Visibility::VISIBILITY_BOTH)
            ->setStatus(1)
            ->setPrice(199.00);

        $link = $this->linkFactory->create();
        $link->setTitle($name . ' - License File')
            ->setLinkType('file')
            ->setLinkFile($filePath)
            ->setNumberOfDownloads($downloadsAllowed)
            ->setSortOrder(1)
            ->setIsShareable(0);

        $extensionAttributes = $this->extensionFactory->create();
        $extensionAttributes->setDownloadableProductLinks([$link]);
        $product->setExtensionAttributes($extensionAttributes);

        $savedProduct = $this->productRepository->save($product);

        return (int) $downloadsAllowed;
    }
}

6. Secure delivery: custom controller with signed URLs

Magento's default download controller resolves the hash and delivers the file via readfile(), which is sufficient for most shops. For sensitive downloadable products, for example software with a high black-market value, an additional protection layer is worthwhile: a signed, time-limited URL that is checked in addition to the existing hash. The link in the customer account then no longer remains permanently valid but only for a narrow time window, for example 15 minutes, after which a new signed URL must be requested.

The signature is formed as an HMAC over link ID, expiry timestamp and a server-side secret key, and appended to the URL as a query parameter. The controller verifies before every delivery both the signature and whether the timestamp is still in the future. For the actual file transfer, X-Sendfile or the Nginx equivalent X-Accel-Redirect is more efficient than readfile() in PHP, because the webserver streams the file directly from the file system to the client without buffering the complete content through the PHP process.

Hotlinking, i.e. directly linking to the download URL from external sites, is additionally prevented through checking the Referer header and the short validity period of the signature: even if a URL is shared publicly, it loses its validity after the time window expires, and a third party would need to authenticate through the customer account again to obtain a new signed URL.


declare(strict_types=1);

namespace Mironsoft\DownloadGuard\Controller\Download;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\Result\RawFactory;
use Magento\Framework\Exception\LocalizedException;
use Mironsoft\DownloadGuard\Model\DownloadAccessValidator;
use Mironsoft\DownloadGuard\Model\SignedUrlValidator;

/**
 * Streams a purchased downloadable file only if the signed URL is still valid
 * and the order-status gate in DownloadAccessValidator allows access.
 */
class SecureFile implements HttpGetActionInterface
{
    /**
     * @param RequestInterface $request Current HTTP request
     * @param RawFactory $rawResultFactory Factory for raw binary responses
     * @param DownloadAccessValidator $accessValidator Order-status and limit gate
     * @param SignedUrlValidator $signedUrlValidator Validates the HMAC signature and expiry
     */
    public function __construct(
        private readonly RequestInterface $request,
        private readonly RawFactory $rawResultFactory,
        private readonly DownloadAccessValidator $accessValidator,
        private readonly SignedUrlValidator $signedUrlValidator,
    ) {
    }

    /**
     * Executes the secure download action.
     *
     * @return ResponseInterface|\Magento\Framework\Controller\Result\Raw
     * @throws LocalizedException If the signature is invalid or expired
     */
    public function execute()
    {
        $hash = (string) $this->request->getParam('hash');
        $signature = (string) $this->request->getParam('sig');
        $expires = (int) $this->request->getParam('exp');

        $this->signedUrlValidator->assertValid($hash, $signature, $expires);
        $item = $this->accessValidator->validate($hash);

        $filePath = $item->getLinkFilePath();

        $result = $this->rawResultFactory->create();
        $result->setHeader('Content-Type', 'application/octet-stream');
        $result->setHeader('Content-Disposition', 'attachment; filename="' . basename($filePath) . '"');
        // Offload the actual transfer to the webserver instead of buffering in PHP
        $result->setHeader('X-Accel-Redirect', '/protected-downloads/' . rawurlencode(basename($filePath)));

        return $result;
    }
}

7. License key management: a custom module for a license pool

As soon as downloadable products represent real software licenses, plain file delivery is no longer sufficient, an individual license key is needed per order item. The suitable pattern is a custom module with a license-pool table, declared via db_schema.xml, holding pre-generated keys marked as "available". An observer on sales_order_place_after then reserves exactly one free key from the pool per purchased downloadable product and permanently links it to the respective order_item_id.

What matters for data integrity is securing the assignment transactionally with a unique database-level lock, for example via SELECT ... FOR UPDATE when reading the next free key, so that the same license key is never assigned twice under concurrent orders. The foreign-key relationship to sales_order_item additionally ensures that a key can automatically be marked as revoked when the associated order is later fully refunded.


<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">

    <!-- License pool: pre-generated keys for downloadable license products -->
    <table name="mironsoft_license_pool" resource="default" engine="innodb"
           comment="License key pool for downloadable products">
        <column xsi:type="int" name="entity_id" padding="10" unsigned="true"
                nullable="false" identity="true" comment="Entity ID"/>
        <column xsi:type="int" name="product_id" padding="10" unsigned="true"
                nullable="false" comment="Linked downloadable product ID"/>
        <column xsi:type="varchar" name="license_key" nullable="false" length="128"
                comment="Generated license key"/>
        <column xsi:type="int" name="order_item_id" padding="10" unsigned="true"
                nullable="true" comment="Assigned sales order item ID"/>
        <column xsi:type="smallint" name="status" padding="5" unsigned="true"
                nullable="false" default="0" comment="0 available, 1 assigned, 2 revoked"/>
        <column xsi:type="timestamp" name="assigned_at" nullable="true"
                comment="Timestamp of assignment"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="entity_id"/>
        </constraint>
        <constraint xsi:type="unique" referenceId="MIRONSOFT_LICENSE_POOL_LICENSE_KEY">
            <column name="license_key"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="MIRONSOFT_LICENSE_POOL_ORDER_ITEM_ID_SALES_ORDER_ITEM_ITEM_ID"
                    table="mironsoft_license_pool" column="order_item_id"
                    referenceTable="sales_order_item" referenceColumn="item_id" onDelete="SET NULL"/>
        <index referenceId="MIRONSOFT_LICENSE_POOL_PRODUCT_ID_STATUS" indexType="btree">
            <column name="product_id"/>
            <column name="status"/>
        </index>
    </table>
</schema>

8. Email delivery: transactional emails with download links

After a downloadable product is unlocked, Magento automatically sends a transactional email with the download links, controlled via Magento\Downloadable\Model\Link\Purchased and the template area under Stores > Configuration > Customer Configuration > Downloadable Product Options. The default template downloadable_email_links_template can be customized via a custom layout handle, for example to additionally display the assigned license key from the pool described in section 7 inside the email.

In case a customer loses the original email or a download link needs to be regenerated, the admin area under Sales > Orders > Downloadable Information offers the option to resend links without altering the order itself. Technically, this admin button triggers the same mail-sending service as the automatic dispatch after invoicing, so that custom plugins that, for example, inject the license key, consistently take effect in both places, as long as they hook into the Service Contract rather than the controller.


declare(strict_types=1);

namespace Mironsoft\DownloadGuard\Plugin;

use Magento\Downloadable\Model\Link\Purchased;
use Mironsoft\LicensePool\Api\LicenseKeyRepositoryInterface;

/**
 * Injects the assigned license key into the downloadable link email data
 * so customers receive their key alongside the download link.
 */
class AddLicenseKeyToEmailPlugin
{
    /**
     * @param LicenseKeyRepositoryInterface $licenseKeyRepository Repository for pooled license keys
     */
    public function __construct(
        private readonly LicenseKeyRepositoryInterface $licenseKeyRepository,
    ) {
    }

    /**
     * Adds the license key as an extra template variable before the mail is sent.
     *
     * @param Purchased $subject The purchased-link aggregate being emailed
     * @param array $result Template variables passed to the transactional email
     * @return array Modified template variables including the license key
     */
    public function afterGetEmailVariables(Purchased $subject, array $result): array
    {
        $orderItemId = (int) $subject->getData('order_item_id');
        $licenseKey = $this->licenseKeyRepository->getByOrderItemId($orderItemId);

        if ($licenseKey !== null) {
            $result['license_key'] = $licenseKey->getLicenseKey();
        }

        return $result;
    }
}

9. Downloadable products compared: storage and security options

When configuring downloadable products, several technical decisions come into play that directly affect security, performance and use case. The following overview summarizes the most important options, as they are typically configured in Magento 2 for digital goods with varying protection needs.

Option Security Performance Use Case
Link type "file" High, access runs through the controller Depends on storage backend License-bound software, sensitive files
Link type "url" Low, if unprotected externally High, CDN-capable Large media files with their own protection
Sample instead of link Deliberately public Uncritical Sample chapter, preview without purchase
Download limit unlimited (0) Low, no abuse protection Uncritical Lenient customer experience, low risk
Download limit restricted High, technical brake on sharing Uncritical Software licenses, commercial content
Signed, time-limited URL Very high, prevents hotlinking Extra logic per request High-value, sensitive downloads

10. Summary

Downloadable products in Magento 2 solve a fundamentally different problem than physical products: once a file has been delivered, it escapes any further control by the shop. The technical answer is a layered model: link_url_key ensures the real file path is never visible, order-status gating prevents access before payment is complete, download limits slow down uncontrolled redistribution, and a custom license-key module ties the purchase to a concrete, individual entitlement.

Anyone implementing these layers cleanly via Service Contracts, db_schema.xml and repositories instead of direct model manipulation gets a setup that evolves with Magento updates instead of needing to be re-patched with every upgrade. For agencies distributing software licenses or high-value digital content through downloadable products, this investment in link security and access control is not an optional extra but a basic requirement for a viable licensing business.

Downloadable Products in Magento 2: The Essentials at a Glance

Data model

downloadable_link, downloadable_sample and downloadable_link_purchased cleanly separate product definition, order context and usage counter.

Link security

link_url_key as a random hash, the real file path is never visible. link_type "file" instead of "url" for sensitive downloads.

Access control

Order-status gating and number_of_downloads limits prevent premature and unlimited access.

License pool & signed URLs

Custom module with db_schema.xml for license keys, plus signed, time-limited download URLs against hotlinking.

11. FAQ: Downloadable Products in Magento 2

1Downloadable product vs. virtual product?
Virtual product represents a service without delivery. Downloadable product delivers real files through its own infrastructure with link hashing and download limits.
2Which tables store downloadable products?
downloadable_link and downloadable_sample for product data, downloadable_link_purchased and downloadable_link_purchased_item for order context and usage counters.
3How does link_url_key protect?
A random hash replaces the real file path in the frontend. The controller resolves it server-side, the customer never sees the actual storage location.
4link_type file vs. url?
file stores internally with full access protection. url points externally and can be directly callable and unprotected if no separate protection exists.
5When is the download unlocked?
Only once the order status is reached, usually Pending or Invoiced. Before that, the controller denies access, even with a correct hash.
6How does the download limit work?
number_of_downloads sets the maximum, 0 means unlimited. Every download increases number_of_downloads_used, checked before every delivery.
7Creating one programmatically?
ProductRepositoryInterface with typeId downloadable, set links via LinkInterfaceFactory as an extension attribute, then call save().
8What are signed URLs for?
Prevent permanent validity and hotlinking. After the time window expires, a new signed URL must be requested through the customer account.
9Building a license key pool?
Custom module with db_schema.xml and an observer on sales_order_place_after that transactionally reserves keys and links them to the order_item_id.
10Is the default configuration enough?
For simple digital goods, usually yes. For software licenses, a license pool, signed URLs and a strict download limit are additionally recommended.

Mironsoft

Magento 2 development, Hyva themes and e-commerce security

Setting up downloadable products securely in Magento 2?

We design and implement the right architecture for your downloadable products, from link security through download limits to a custom license-key pool, cleanly built via Service Contracts and db_schema.xml.

Security audit

Reviewing existing downloadable products for link security and access control

License pool module

Custom module for license-key management and automatic assignment

Signed delivery

Custom controller with time-limited, signed download URLs