> ## Documentation Index
> Fetch the complete documentation index at: https://payload-storage-bunny.seshuk.im/llms.txt
> Use this file to discover all available pages before exploring further.

# Signed URLs

> Add time limits, country restrictions, and IP locking to Bunny Storage and Stream file links.

Signed URLs add a time limit — and optionally a country restriction or an [IP lock](#ip-locking-userip) — to file and video links, so they can't be shared or reused forever. They work for both [Storage](/v4/configuration/storage/overview) and [Stream](/v4/configuration/stream/overview), as long as the matching `tokenSecurityKey` is set.

```ts payload.config.ts theme={null}
bunnyStorage({
  collections: { media: true },
  storage: {
    // ...
    tokenSecurityKey: process.env.BUNNY_STORAGE_TOKEN_SECURITY_KEY,
  },
  signedUrls: true, // enable with defaults (2-hour expiry)
})
```

<Info>
  Signed URLs secure **downloads** (how files are served). For securing **uploads** — minting short-lived URLs so the browser can send files straight to Bunny — see [Client uploads](/v4/configuration/storage/client-uploads).
</Info>

## Options

| Option | Type | Default | Description |
| - | - | - | - |
| `expiresIn` | `number \| (args) => Date \| number \| undefined` | `7200` | Seconds from now, or a function for a per-URL or absolute expiry. See [Expiry](#expiry-expiresin). |
| `allowedCountries` | `string[]` | – | ISO 3166-1 alpha-2 codes. Only these countries can use the link. |
| `blockedCountries` | `string[]` | – | ISO 3166-1 alpha-2 codes. These countries are rejected. |
| `shouldUseSignedUrl` | `(args: { collection, filename, req? }) => boolean` | always sign | Return `false` to skip signing for specific files. |
| `userIp` | `(args: { collection, filename, req }) => string \| undefined` | – | Lock links to the client's IPv4 address. See [IP locking](#ip-locking-userip). |
| `staticHandler` | `{ redirect?: boolean \| { expiresIn?, status? } }` | – | Controls how Payload serves files when its access control is on. See below. |

## Getting your token security keys

<Tabs>
  <Tab title="Storage">
    Bunny dashboard → **Delivery** → **CDN** → your Pull Zone → **Security** → **Token Authentication** → enable it and
    copy the key.
  </Tab>

  <Tab title="Stream">
    Bunny dashboard → **Delivery** → **Stream** → your Video Library → **Security** → **General** → enable **CDN Token
    Authentication** and copy the key.
  </Tab>
</Tabs>

## Requirements

* If `signedUrls` is enabled anywhere `storage` is configured, `storage.tokenSecurityKey` is required.
* If `signedUrls` is enabled anywhere `stream` is configured, `stream.tokenSecurityKey` is required.

Without the matching key, the plugin's options validator throws a startup error.

<Info>
  The `tokenSecurityKey` is per zone / per library. When a collection points at [its own storage zone or stream library](/v4/configuration/collection-overrides#own-zone-or-library-per-collection), that zone/library must carry its own `tokenSecurityKey` for signed URLs to work on it — the global key isn't inherited.
</Info>

<Note>
  A Bunny token covers the query string, so any parameter added after signing invalidates it. Payload's `upload.cacheTags` appends the document's `updatedAt` to admin thumbnails, which would break those links — the plugin therefore sets `cacheTags: false` on collections that hand signed CDN URLs straight to the browser (`disablePayloadAccessControl: true`). Filenames are already unique per upload, so cache busting isn't needed there.
</Note>

## Proxy vs. redirect (`staticHandler`)

When a collection uses Payload's access control (the default), a file request goes through Payload first. `staticHandler` controls what happens next:

| Option | Type | Default | Description |
| - | - | - | - |
| `redirect` | `boolean \| object` | `false` | `true` or an object sends a redirect to a signed Bunny URL. `false` proxies the file through Payload. |
| `redirect.status` | `302 \| 307` | `302` | HTTP status code for the redirect. Permanent codes are not allowed, because signed links expire. |
| `redirect.expiresIn` | `number \| (args) => Date \| number \| undefined` | uses the main `expiresIn` | Expiry for redirect links only. A function gets the inherited redirect expiry as `defaultValue`, else the main `expiresIn` result. |

```ts theme={null}
signedUrls: {
  expiresIn: 3600,
  staticHandler: {
    redirect: {
      status: 302,
      expiresIn: 1800, // redirect links expire faster than direct signed URLs
    },
  },
}
```

<Tip>
  **Redirect is recommended**

  Proxying downloads the file from Bunny to your server and back out to the user on every request — a redirect sends the user straight to Bunny's CDN, so your server does none of that work. It also lets Stream videos play as HLS instead of falling back to MP4 — see [Access control and mp4Fallback](/v4/configuration/stream/overview#access-control-and-mp4-fallback).
</Tip>

## IP locking (`userIp`)

`userIp` binds each signed link to a single client IPv4 address — a leaked URL stops working from any other IP. You provide the callback because reliably extracting the real client IP depends on your host and proxy chain:

```ts theme={null}
signedUrls: {
  userIp: ({ req }) => {
    // Adjust to your deployment: pick the header your proxy/CDN sets.
    const forwarded = req.headers.get('x-forwarded-for')
    return forwarded?.split(',')[0]?.trim() || undefined
  },
}
```

How it behaves:

* The IP goes into the token hash only — it is never added to the URL.
* When the collection sets `generateFileURL`, the plugin keeps that URL and does not re-sign it with the IP.
* Return a falsy value when no IP can be determined; the link is then signed **without** an IP lock rather than failing the request.
* The callback runs only for URLs the client itself will open — document `url` fields, admin thumbnails, and `staticHandler` redirects. URLs your server fetches on the client's behalf (proxied downloads) and URLs generated outside a request (cache purging) are never IP-locked, since Bunny would see your server's IP there, not the client's.

<Warning>
  **Bunny-side requirements**

  * Enforcement requires **Token IP Validation** to be enabled on Bunny: Pull Zone → **Security** → **Token Authentication** for Storage, or the equivalent setting on your Stream library. Without it, Bunny ignores the IP in the hash — but a token signed *with* an IP won't validate, so only enable `userIp` together with the Bunny-side setting.
  * Only **IPv4** is supported; enabling Token IP Validation disables IPv6 routing on the Pull Zone. Values that aren't a plain IPv4 address are ignored (the link is signed without a lock) and a warning is logged.
</Warning>

<Info>
  Clients behind carrier-grade NAT or switching networks (mobile) can legitimately change IPs mid-session, which breaks IP-locked links. Prefer short `expiresIn` values over IP locking for those audiences, or combine both with a tolerant `userIp` callback.
</Info>

## Expiry (`expiresIn`)

A number is a sliding window: the link expires that many seconds after it is signed. A function decides per URL. It gets `{ collection, filename, req?, defaultValue }` and returns:

* a number: seconds from now,
* a `Date`: a fixed time, for example the end of a live event or a rental period,
* `undefined`: use `defaultValue`.

`defaultValue` is the value the URL would get without your function: the global `expiresIn` result on a collection, or `7200` in the global options.

```ts theme={null}
signedUrls: {
  expiresIn: ({ defaultValue, filename }) => (filename.startsWith('event/') ? eventEndsAt : defaultValue),
}
```

A number must be more than 0 and at most 10 years, because a UNIX timestamp passed as a number is almost always a mistake. The plugin checks a fixed number at startup and a returned number when it signs the URL. Return a `Date` for a fixed time.

## Example: sign only videos, restrict by country

```ts theme={null}
signedUrls: {
  expiresIn: 7200,
  allowedCountries: ['US', 'CA', 'GB', 'AU'],
  shouldUseSignedUrl: ({ filename }) => /\.(mp4|webm|mov|avi)$/i.test(filename),
  staticHandler: {
    redirect: true,
  },
}
```

## Collection overrides

```ts theme={null}
collections: {
  privateDocs: {
    signedUrls: {
      expiresIn: 1800,
    },
  },
  publicMedia: {
    signedUrls: false, // disable signing for this collection
  },
}
```

See [Collection overrides](/v4/configuration/collection-overrides) for the full list of options you can tune per collection.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.