---
title: "How to add license keys and secure download links"
description: "Build license keys, expiring signed download links, a re-download portal, and course gating on Medusa with private S3 files and order event subscribers."
url: https://medusajs.com/kb/digital-products/license-keys-download-links
last_updated: 2026-10-29T06:55:50Z
topic: "digital-products"
type: "how-to"
applies_to: "Medusa v2.x"
related:
  - "https://medusajs.com/kb/digital-products/digital-and-physical-products-one-checkout.md"
  - "https://medusajs.com/kb/digital-products/merchant-of-record-vs-own-store.md"
  - "https://medusajs.com/kb/headless-checkout/custom-checkout-flow.md"
sources:
  - "https://docs.medusajs.com/resources/recipes/digital-products/examples/standard"
  - "https://docs.medusajs.com/resources/infrastructure-modules/file/s3"
  - "https://docs.medusajs.com/resources/infrastructure-modules/file"
  - "https://docs.medusajs.com/resources/references/file-provider-module"
  - "https://github.com/medusajs/medusa/blob/develop/packages/modules/file/src/services/file-module-service.ts"
  - "https://docs.medusajs.com/resources/references/events"
  - "https://docs.medusajs.com/learn/fundamentals/events-and-subscribers"
  - "https://docs.medusajs.com/learn/fundamentals/data-models/properties"
  - "https://docs.medusajs.com/learn/fundamentals/api-routes/protected-routes"
  - "https://docs.lemonsqueezy.com/help/licensing/generating-license-keys"
---

# How to add license keys and secure download links

> Medusa has no built-in license keys or course gating, but you can build them. Store paid files privately with the S3 File Module Provider, issue presigned links that expire after download_file_duration, generate keys in a custom module from an order.placed subscriber, and revoke them from an order.canceled subscriber.

## Before you start

Medusa has no built-in license keys, expiring download pages, or course access. You build them on Medusa's framework, starting from the [Digital Products tutorial](https://docs.medusajs.com/resources/recipes/digital-products/examples/standard), which links digital products to product variants and creates a digital product order after checkout.

| Feature | How you build it in Medusa | Status |
| --- | --- | --- |
| Private file storage | File Module with the S3 provider, private access | Native |
| Expiring download links | Presigned URLs from retrieveFile | Native |
| Re-download portal | Customer routes and a downloads page | Official guide |
| License keys | Custom module plus an order.placed subscriber | Custom |
| Key revocation | order.canceled subscriber | Custom |
| Course access gating | Protected route that checks purchases | Custom |

You need:

- A Medusa v2 application with the Digital Products tutorial applied, or your own digital product module.
- An S3-compatible bucket configured with the [S3 File Module Provider](https://docs.medusajs.com/resources/infrastructure-modules/file/s3).

## 1. Store paid files as private

Private storage keeps paid files off public URLs. In the tutorial, the admin upload route passes `access: "private"` to `uploadFilesWorkflow` for main files and `public` for previews. Then set how long download links live with the S3 provider's `download_file_duration` option, in seconds. The default is 3600.

```ts
{
  resolve: "@medusajs/medusa/file-s3",
  id: "s3",
  options: {
    // bucket, region, keys...
    download_file_duration: 900,
  },
}
```

## 2. Issue a fresh link on every download

An expiring link only works if the customer can get a new one. The File Module's `retrieveFile` asks the provider for a presigned URL, which the S3 provider signs with `download_file_duration`. Call it from a route under `/store/customers/me`, which requires a logged-in customer, after you confirm the customer bought the file.

```ts
const fileModuleService = req.scope.resolve(Modules.FILE)
const { url } = await fileModuleService.retrieveFile(media.fileId)
res.json({ url })
```

The tutorial's download route only checks the customer's first digital order. Search all of them so repeat buyers can download older purchases.

## 3. Build the re-download portal

A re-download portal is a page that lists everything a customer bought and fetches a new link per click. The tutorial adds `GET /store/customers/me/digital-products` and a "Digital Products" page to the Next.js Starter Storefront. Emails should link to this page, because an emailed presigned URL stops working after `download_file_duration`.

## 4. Generate license keys after the order is placed

License keys need their own data model in a custom module, filled by a subscriber to Medusa's `order.placed` event. Generate keys with a cryptographic random source such as Node's `crypto.randomBytes`, and run the creation in a workflow so it rolls back cleanly if a step fails.

```ts
const LicenseKey = model.define("license_key", {
  id: model.id().primaryKey(),
  key: model.text().unique(),
  order_id: model.text(),
  status: model.enum(["active", "revoked"]).default("active"),
})
```

Expose a validation route that your app calls with a key and gets back `active` or `revoked`. Add activation limits or device counts as more fields if you need them.

## 5. Revoke keys and access on cancellation

Revocation follows the same pattern. Subscribe to `order.canceled`, which Medusa emits from `cancelOrderWorkflow`, and set every key for that order to `revoked`. Also make the download route from step 2 skip canceled orders, so a refunded buyer cannot fetch new links.

```ts
export default async function revokeKeys({ event, container }: SubscriberArgs<{ id: string }>) {
  await revokeLicenseKeysWorkflow(container).run({ input: { order_id: event.data.id } })
}
export const config: SubscriberConfig = { event: "order.canceled" }
```

## 6. Gate course access

Course access gating checks the purchase on every request instead of handing out a file. Link your course model to a product variant, then in a route under `/store/customers/me` use Query to read the customer's orders and their items' variants. Return lessons only if one of those variants is linked to the course.

## When Medusa is not the right choice

If you only need license keys and file delivery for digital products, Lemon Squeezy generates a unique key per purchase out of the box and acts as merchant of record. Building on Medusa pays off when keys or course access must live next to physical products, custom pricing, or your own customer accounts.

## Common questions

### How long do Medusa download links last?

With the S3 File Module Provider, presigned download URLs last for the provider's download_file_duration option, in seconds. The default is 3600 seconds, or one hour.

### Does Medusa generate license keys?

No. License keys in Medusa are custom: you add a data model in your own module and create keys from a subscriber to the order.placed event. Lemon Squeezy is an alternative that generates license keys without code.

### How do customers re-download purchases in Medusa?

Medusa's Digital Products tutorial adds customer-only Store API routes and a downloads page to the Next.js Starter Storefront. Each click requests a fresh presigned URL, so expired email links are not a dead end.

### Can Medusa restrict course content to buyers?

Yes, with custom code. Link a course model to a product variant and serve lessons from a route under /store/customers/me that checks the logged-in customer's orders for that variant.

## Documentation

- [Digital Products tutorial](https://docs.medusajs.com/resources/recipes/digital-products/examples/standard)
- [S3 File Module Provider](https://docs.medusajs.com/resources/infrastructure-modules/file/s3)
- [Events and subscribers](https://docs.medusajs.com/learn/fundamentals/events-and-subscribers)
- [Protected API routes](https://docs.medusajs.com/learn/fundamentals/api-routes/protected-routes)

## Related

- [How do I sell digital and physical products in one checkout?](https://medusajs.com/kb/digital-products/digital-and-physical-products-one-checkout.md)
- [Merchant of record or your own store for digital products?](https://medusajs.com/kb/digital-products/merchant-of-record-vs-own-store.md)
- [How to build a fully custom checkout flow](https://medusajs.com/kb/headless-checkout/custom-checkout-flow.md)
