PHPackages                             restruct/silverstripe-signed-asset-urls - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. restruct/silverstripe-signed-asset-urls

ActiveSilverstripe-vendormodule[Utility &amp; Helpers](/categories/utility)

restruct/silverstripe-signed-asset-urls
=======================================

Time-expiring signed URLs for SilverStripe assets

1.1.2(2mo ago)095↓33.3%[1 issues](https://github.com/restruct/silverstripe-signed-asset-urls/issues)MITPHP

Since Jan 16Pushed 2mo agoCompare

[ Source](https://github.com/restruct/silverstripe-signed-asset-urls)[ Packagist](https://packagist.org/packages/restruct/silverstripe-signed-asset-urls)[ RSS](/packages/restruct-silverstripe-signed-asset-urls/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependencies (4)Versions (5)Used By (0)

Signed Protected Asset URLs for SilverStripe
============================================

[](#signed-protected-asset-urls-for-silverstripe)

Time-expiring signed URLs for protected SilverStripe assets, similar to Amazon S3 pre-signed URLs.

Features
--------

[](#features)

- **Time-limited URLs**: Assets are only accessible for a configurable duration
- **Session binding**: Optionally restrict URLs to the session that created them
- **HMAC-SHA256 signing**: Cryptographically secure URL signatures
- **Admin bypass**: Logged-in CMS users can access assets without signing
- **Configurable TTL**: Default and per-URL time-to-live settings
- **SilverStripe integration**: Uses SilverStripe's AssetStore for file resolution (works with hash-based paths)

Configuration
-------------

[](#configuration)

### Environment Variables

[](#environment-variables)

Add to your `.env` file:

```
# Required: Secret key for signing URLs (use a long random string)
ASSET_SIGNING_SECRET="your-secret-key-min-32-characters-recommended"
```

### SilverStripe Config

[](#silverstripe-config)

Create `app/_config/signed-asset-urls.yml` to override defaults:

```
Restruct\SilverStripe\SignedAssetUrls\Services\AssetUrlSigningService:
  # Default TTL in seconds (default: 3600 = 1 hour)
  default_ttl: 3600

  # Bind URLs to user session by default (default: false)
  bind_to_session: false

  # Automatically adjust page Cache-Control headers based on signed URL TTLs (default: true)
  auto_cache_headers: true

  # Check if files are published before serving - respects Versioned staging (default: true)
  # Set to false if you don't use Versioned staging on files
  check_published_status: true

  # Permissions that bypass signing (default: uses Versioned::$non_live_permissions)
  # This typically includes CMS_ACCESS_*, VIEW_DRAFT_CONTENT, etc.
  # Set to custom array to override:
  # bypass_permissions:
  #   - 'ADMIN'
  #   - 'CMS_ACCESS_AssetAdmin'
```

All configuration options have sensible defaults - you typically only need to set `ASSET_SIGNING_SECRET` in `.env` to get started.

Web Server Configuration (Optional)
-----------------------------------

[](#web-server-configuration-optional)

By default, files are served via PHP streaming. For better performance on high-traffic sites, you can enable web server file handoff using X-Sendfile (Apache) or X-Accel-Redirect (Nginx).

### Environment Variable

[](#environment-variable)

Add to your `.env` file:

```
# File serving method: 'php' (default), 'apache', or 'nginx'
ASSET_FILE_SERVER=php
```

### Nginx (X-Accel-Redirect)

[](#nginx-x-accel-redirect)

**How it works:** PHP validates the signed URL, then sends an `X-Accel-Redirect` header pointing to an internal nginx location. Nginx intercepts this and serves the file directly from disk, bypassing PHP for the actual file transfer.

#### Setup

[](#setup)

1. Set `ASSET_FILE_SERVER=nginx` in `.env`
2. Add an `internal` location block to your nginx server config (see examples below)
3. Run the verification task to confirm your setup:

    ```
    vendor/bin/sake dev/tasks/SignedAssetUrlVerifyTask
    ```

#### How the location path is determined

[](#how-the-location-path-is-determined)

The module derives the location from `basename(SS_PROTECTED_ASSETS_PATH)`:

`SS_PROTECTED_ASSETS_PATH`X-Accel-Redirect headerRequired nginx location*(not set, default `.protected`)*`/.protected/Uploads/abc123/file.pdf``/.protected/``../restricted_assets``/restricted_assets/Uploads/abc123/file.pdf``/restricted_assets/``/var/www/project/protected_assets``/protected_assets/Uploads/abc123/file.pdf``/protected_assets/`**The location path must match the basename exactly.** A common mistake is using the wrong folder name (e.g., `/protected_assets/` when the env var resolves to a folder named `restricted_assets`).

#### `root` vs `alias`

[](#root-vs-alias)

Both work, but `root` is recommended — it's more portable:

- **`root`** — nginx **appends** the full URI to the root path. Point `root` at the **parent** of the protected folder: ```
    location /restricted_assets/ + root .../current
    → .../current/restricted_assets/Uploads/abc123/file.pdf  ✓

    ```
- **`alias`** — nginx **replaces** the location prefix with the alias path. Point `alias` at the **exact** folder (trailing slash required): ```
    location /restricted_assets/ + alias .../current/restricted_assets/
    → .../current/restricted_assets/Uploads/abc123/file.pdf  ✓

    ```

With `root`, if you rename the folder, you only change the location — not the root. With `alias`, you repeat the full path. Either way, the path must be absolute — nginx does not resolve `..`.

**Common mistake — `root` pointing to the folder itself:**

```
# WRONG: root points to the folder → path doubled!
location /restricted_assets/ + root .../current/restricted_assets
→ .../current/restricted_assets/restricted_assets/file.pdf  ✗

```

#### Can I reuse the server root?

[](#can-i-reuse-the-server-root)

No. The `server { root ... }` points to the public webroot (e.g., `.../current/public`), but protected assets live **outside** `public/` as a sibling (e.g., `.../current/restricted_assets`). Nginx does not resolve `..` in `root`/`alias` directives, so you need a separate absolute path.

#### Configuration examples

[](#configuration-examples)

**Default setup** (`.protected` inside `public/assets/`):

```
location /.protected/ {
    internal;
    root /path/to/project/public/assets;
}
```

**Custom `SS_PROTECTED_ASSETS_PATH`** (folder outside webroot):

```
# If SS_PROTECTED_ASSETS_PATH resolves to .../current/restricted_assets
location /restricted_assets/ {
    internal;
    root /path/to/project;
}
```

**Laravel Forge with zero-downtime deployments:**

```
# server root: /home/forge/sitename.com/current/public
# SS_PROTECTED_ASSETS_PATH: /home/forge/sitename.com/current/restricted_assets

location /restricted_assets/ {
    internal;
    root /home/forge/sitename.com/current;
}
```

#### Where to place it

[](#where-to-place-it)

Add the location block **after the PHP location block**, at the bottom of the `server { }` block. Since it's `internal`, ordering with other locations doesn't matter — but keeping it at the bottom makes it easy to spot as a non-standard addition.

```
server {
    root /home/forge/sitename.com/current/public;
    # ... standard config ...

    location ~ \.php$ {
        # ... PHP-FPM ...
    }

    # Signed asset URLs — X-Accel-Redirect
    location /restricted_assets/ {
        internal;
        root /home/forge/sitename.com/current;
    }
}
```

#### The `internal` directive

[](#the-internal-directive)

This makes the location only respond to `X-Accel-Redirect` headers from PHP. Direct browser requests to `/restricted_assets/...` return 404. Without `internal`, anyone could bypass the signed URL check by requesting the path directly.

### Apache (X-Sendfile)

[](#apache-x-sendfile)

1. Install and enable mod\_xsendfile:

    ```
    sudo a2enmod xsendfile
    sudo systemctl restart apache2
    ```
2. Set `ASSET_FILE_SERVER=apache` in `.env`
3. Add to your Apache config or `.htaccess`:

    **Default setup**:

    ```

        XSendFile On
        XSendFilePath /path/to/project/public/assets/.protected

    ```

    **Custom setup** (using `SS_PROTECTED_ASSETS_PATH`):

    ```

        XSendFile On
        XSendFilePath /path/to/project/protected_assets

    ```
4. Run the verification task to get your exact configuration:

    ```
    vendor/bin/sake dev/tasks/SignedAssetUrlVerifyTask
    ```

**How it works**: PHP validates the signed URL, then sends an `X-Sendfile` header with the absolute file path. Apache serves the file directly, bypassing PHP for the actual file transfer.

### Performance Considerations

[](#performance-considerations)

MethodProsCons**PHP** (default)No server config needed, works everywhereHigher memory usage, slower for large files**Nginx**Very fast, low memoryRequires nginx config access**Apache**Fast, works with .htaccessRequires mod\_xsendfile installationFor most sites, PHP streaming is sufficient. Consider web server handoff if you:

- Serve many large files (videos, archives)
- Have high concurrent download traffic
- Need to minimize PHP memory usage

Usage
-----

[](#usage)

### In PHP

[](#in-php)

```
use SilverStripe\Assets\File;

// Get a file and generate signed URL
$file = File::get()->byID(123);
$signedUrl = $file->SignedURL();  // Uses default TTL

// Custom TTL (2 hours)
$signedUrl = $file->SignedURL(7200);

// Session-bound URL (only works for current user's session)
$signedUrl = $file->SignedURL(3600, true);

// Using named policies (see Policies section below)
$signedUrl = $file->AutoURL('ss');  // 30s, session-bound
$signedUrl = $file->AutoURL('m');   // 1 hour, not session-bound

// Check if a file requires signed URLs (useful for conditional logic)
if ($file->requiresSignedURL()) {
    // File is protected - needs signing
}

// Or via the service directly
$service = Injector::inst()->get(AssetUrlSigningService::class);
$signedUrl = $service->generateSignedURL('path/to/file.pdf', 3600);
$signedUrl = $service->generateSignedURL('path/to/file.pdf', 3600, true);  // Session-bound
```

### In Templates

[](#in-templates)

```

Download File

Download File (2hr)

Download (30s, session-bound)
Download (1hr)
Download (24hr, session-bound)

    Protected Download

    Public Download

```

### Available Methods

[](#available-methods)

MethodDescription`$File.SignedURL`Signed URL with default TTL (returns normal URL for public files)`$File.SignedURL(ttl)`Signed URL with custom TTL in seconds`$File.SignedURL(ttl, bindToSession)`Signed URL with TTL and session binding`$File.AutoURL`Same as SignedURL (auto-detects if signing needed)`$File.AutoURL('policy')`Signed URL using named policy (e.g., 'ss', 'm')`$File.AutoURL(ttl)`Signed URL with custom TTL in seconds`$File.RequiresSignedURL`Boolean: true if file is protectedPolicies
--------

[](#policies)

Named policies provide convenient presets for TTL and session binding. Use them in templates for cleaner, more maintainable code.

### Built-in Policies

[](#built-in-policies)

PolicyTTLSession-boundUse case`ss`30 secYesHighly sensitive, immediate use only`s`30 secNoShareable but very short-lived`ms`1 hourYesSensitive documents, single-user access`m`1 hourNoGeneral protected content, shareable`ls`24 hoursYesLong-lived user-specific access`l`24 hoursNoLong-lived shareable links### Custom Policies

[](#custom-policies)

Define your own policies in YAML config:

```
Restruct\SilverStripe\SignedAssetUrls\Services\AssetUrlSigningService:
  policies:
    # Override or add policies
    instant: { ttl: 10, session: true }           # 10 seconds, session-bound
    download: { ttl: 300, session: false }        # 5 minutes for downloads
    preview: { ttl: 1800, session: true }         # 30 min preview, session-bound
```

Usage:

```
Download

```

URL Formats
-----------

[](#url-formats)

Generated URLs use S3-style query parameters for clean, readable paths:

```
# Standard signed URL
/signed-asset/{path}?s={hash}&e={expires}

# Session-bound signed URL
/signed-asset/{path}?s={hash}&e={expires}&ss=1

```

- `path`: Path to asset (FileFilename in SilverStripe)
- `s`: 16-character HMAC-SHA256 signature
- `e`: Unix timestamp when link expires
- `ss`: Session-bound flag (URL only valid for same session)

Examples:

```
/signed-asset/uploads/documents/report.pdf?s=a1b2c3d4e5f6g7h8&e=1704672000
/signed-asset/uploads/documents/report.pdf?s=a1b2c3d4e5f6g7h8&e=1704672000&ss=1

```

This format keeps the asset path readable (like S3 pre-signed URLs) while signature parameters are in the query string.

Session Binding
---------------

[](#session-binding)

When `bind_to_session` is enabled (globally or per-URL), the signed URL includes a hash of the user's session. This means:

- **URLs are non-transferable**: Sharing the URL won't work for other users
- **Extra security layer**: Even if a URL leaks, it's useless to attackers
- **Use case**: Sensitive documents that should never be shared

The session token is derived from PHP's session ID using HMAC, so the actual session ID is never exposed in the URL.

How It Works
------------

[](#how-it-works)

1. **URL Generation**: PHP generates a signed URL with HMAC hash and expiry timestamp
2. **Request**: User requests the signed URL
3. **Validation**: Controller validates hash and checks expiry
4. **Session Check**: If URL is session-bound, validates against current session
5. **Admin Check**: If user has CMS access, signature validation is bypassed
6. **Serving**: File is streamed via SilverStripe's AssetStore (handles hash-based paths automatically)

Browser &amp; Page Caching
--------------------------

[](#browser--page-caching)

### Automatic Cache Header Management

[](#automatic-cache-header-management)

By default, this module automatically adjusts the page's `Cache-Control` headers to prevent browsers from caching the page longer than the shortest-lived signed URL it contains.

**How it works:**

1. Middleware tracks the earliest expiry time of all signed URLs generated during a request
2. Before sending the response, it adjusts `Cache-Control: max-age` to not exceed that expiry
3. Also sets an `Expires` header for older HTTP caches

**Configuration:**

```
Restruct\SilverStripe\SignedAssetUrls\Services\AssetUrlSigningService:
  # Enable/disable automatic cache header adjustment (default: true)
  auto_cache_headers: true
```

**Example:** If you generate a signed URL with 1-hour TTL, the page response will include:

```
Cache-Control: private, max-age=3600
Expires: Tue, 14 Jan 2026 15:30:00 GMT

```

This ensures browsers won't serve a cached page with expired signed URLs.

### Disabling Auto Cache Headers

[](#disabling-auto-cache-headers)

If you manage cache headers yourself or use a CDN with custom rules:

```
Restruct\SilverStripe\SignedAssetUrls\Services\AssetUrlSigningService:
  auto_cache_headers: false
```

Partial Caching (Template Layer)
--------------------------------

[](#partial-caching-template-layer)

When using SilverStripe's partial caching (``), signed URLs require special consideration because they contain expiration timestamps. Note that auto cache headers (above) handle **browser caching**, while this section covers **server-side template caching**.

### The Problem

[](#the-problem)

```

    Download

```

If your cache lives longer than the signed URL's TTL, users will get expired links.

### Solutions

[](#solutions)

**1. Include TTL window in cache key:**

```

    Download

```

**2. Set cache lifetime shorter than TTL:**

```

    Download

```

**3. Exclude signed URLs from cached blocks:**

```

    $Title
    $Content

Download Document
```

**4. Use session-bound URLs with uncached blocks:**

```

    $Title

    Download

```

### Cache Key Helpers

[](#cache-key-helpers)

You can create a helper method for time-windowed cache keys:

```
// In your PageController or via extension
public function SignedURLCacheWindow(int $windowSeconds = 3600): string
{
    return floor(time() / $windowSeconds);
}
```

```

    Download

```

### Session-Bound URLs and Caching

[](#session-bound-urls-and-caching)

**Never cache session-bound URLs** - they are unique per user session:

```
SignedURL()`, the extension checks if the file needs a signed URL:

```
// SignedUrlDBFileExtension::requiresSignedURL()
// Returns true if file requires signed URL

// Check 1: CanViewType restrictions (includes parent folder inheritance)
if ($file->hasRestrictedAccess()) {
    return true;  // Needs signed URL
}

// Check 2: Versioned staging - unpublished files are protected
if ($file->hasExtension(Versioned::class) && !$file->isPublished()) {
    return true;  // Needs signed URL
}

return false;  // Public file, returns normal URL instead
```

This uses SilverStripe's built-in `hasRestrictedAccess()` method which handles CanViewType checking including parent folder inheritance.

#### 2. URL Serving (Controller)

[](#2-url-serving-controller)

When a signed URL is accessed, the controller validates:

1. **Signature validity**: HMAC hash matches and hasn't expired
2. **Session binding**: If URL is session-bound, validates current session
3. **Published status** (optional): If `check_published_status` is enabled, denies access to unpublished files

CMS users with `bypass_permissions` can always access any file.

### Projects with staging disabled (on File assets)

[](#projects-with-staging-disabled-on-file-assets)

If your project uses versioning only (no draft/live staging):

```
// app/_config.php
File::remove_extension(Versioned::class);

// app/_config/app.yml
SilverStripe\Assets\File:
  extensions:
    versioned: SilverStripe\Versioned\Versioned.versioned
```

In this case:

- `isPublished()` always returns true (no staging = always "published")
- Files are only protected based on CanViewType permissions
- You can disable `check_published_status` for a minor performance gain

```
Restruct\SilverStripe\SignedAssetUrls\Services\AssetUrlSigningService:
  # Check published status when serving (default: true)
  # Disable if you don't use Versioned staging on files
  check_published_status: false
```

Security Considerations
-----------------------

[](#security-considerations)

- **Keep your secret safe**: The `ASSET_SIGNING_SECRET` should be long, random, and never committed to version control
- **Use HTTPS**: Signed URLs should be served over HTTPS to prevent interception
- **Set appropriate TTL**: Balance between usability and security
- **Use session binding**: For sensitive documents, enable `bind_to_session`
- **Rotate secrets**: Consider rotating the signing secret periodically
- **Unpublished files**: By default, unpublished files are not accessible via signed URLs (see Protected Assets section)

Development &amp; Testing
-------------------------

[](#development--testing)

### Verification Task

[](#verification-task)

A BuildTask is included to verify your configuration and test URL generation/validation:

```
vendor/bin/sake dev/tasks/SignedAssetUrlVerifyTask
```

This task will:

1. Check that `ASSET_SIGNING_SECRET` is configured
2. Show the protected folder path and whether it exists
3. Display current configuration values (TTL, session binding, cache headers)
4. Generate a test signed URL and validate its components
5. Test signature validation (valid, invalid, and expired cases)
6. Test session-bound URL generation

Example output:

```
=== Signed Asset URLs Configuration Verification ===

1. Environment variable ASSET_SIGNING_SECRET... OK
2. Protected folder path: /path/to/protected_assets... EXISTS

=== Configuration ===
default_ttl: 3600 seconds
bind_to_session: false
auto_cache_headers: true
check_published_status: true

=== Validation Tests ===
Valid signature: PASS
Wrong hash (expect invalid): PASS
Expired URL (expect expired): PASS

```

### Manual Testing Checklist

[](#manual-testing-checklist)

After making changes to the controller or signing service, verify the following:

#### 1. Build and config verification

[](#1-build-and-config-verification)

```
vendor/bin/sake dev/build flush=1
vendor/bin/sake dev/tasks/SignedAssetUrlVerifyTask
```

Both should complete without errors. The verify task checks signing secret, protected folder path, URL generation, and signature validation (valid, invalid, expired).

#### 2. Signature validation (curl)

[](#2-signature-validation-curl)

Test that invalid signatures are rejected:

```
# Should return HTTP 403 (invalid signature)
curl -k -s -o /dev/null -w "%{http_code}" \
  "https://your-site.loc/signed-asset/path/to/file.pdf?s=invalidsignature&e=9999999999"
```

#### 3. File serving (browser)

[](#3-file-serving-browser)

Load a page that renders signed asset URLs (e.g. a page with protected images or download links). Verify:

- Images load correctly (check browser dev tools Network tab for 200 responses)
- Download links work and serve the correct file
- URLs contain the expected `?s=...&e=...` query parameters

#### 4. File path resolution

[](#4-file-path-resolution)

The controller uses framework-based file resolution (`resolveFilePath()`) which handles both hash-based paths (`Uploads/abc1234567/file.pdf`) and natural paths (`Uploads/file.pdf`). To verify resolution works for a specific file:

```
# Find a file with a known hash
vendor/bin/sake dev/tasks/orm-query class=File "filter[FileHash:not]=" limit=3 fields=ID,FileFilename,FileHash

# Check which storage layout is used on disk
ls {protected_folder}/{dirname}/{hash10}/{basename}   # hash path
ls {protected_folder}/{dirname}/{basename}             # natural path
```

The signed URL for that file should work regardless of which layout exists on disk.

#### 5. Web server handoff (if configured)

[](#5-web-server-handoff-if-configured)

If `ASSET_FILE_SERVER` is set to `apache` or `nginx`:

- Verify files are served without PHP streaming (check response headers for `X-Sendfile` or `X-Accel-Redirect` — note these headers are consumed by the web server and won't appear in browser dev tools)
- Check PHP memory usage stays low when serving large files
- If handoff fails, the controller falls through to PHP streaming automatically

#### What is NOT covered by automated tests

[](#what-is-not-covered-by-automated-tests)

This module has no PHPUnit tests. The `resolveFilePath()` method requires a fully bootstrapped SilverStripe environment with actual filesystem adapters, making unit testing impractical without integration test infrastructure. All testing is manual via the checklist above and the `SignedAssetUrlVerifyTask`.

###  Health Score

37

—

LowBetter than 83% of packages

Maintenance86

Actively maintained with recent releases

Popularity13

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity37

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~17 days

Total

4

Last Release

70d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/4d3680d6353e5f171543435b89965ba2588186ad7ec0ec97cbf572704fec2a4f?d=identicon)[micschk](/maintainers/micschk)

---

Top Contributors

[![micschk](https://avatars.githubusercontent.com/u/1005986?v=4)](https://github.com/micschk "micschk (10 commits)")

---

Tags

silverstripeassetsprotectedsigned-urlsx-sendfileX-Accel-Redirect

### Embed Badge

![Health badge](/badges/restruct-silverstripe-signed-asset-urls/health.svg)

```
[![Health](https://phpackages.com/badges/restruct-silverstripe-signed-asset-urls/health.svg)](https://phpackages.com/packages/restruct-silverstripe-signed-asset-urls)
```

###  Alternatives

[silverstripe/blog

A fresh take on blogging in Silverstripe set out to tackle the issue of a cluttered Site Tree.

104739.2k31](/packages/silverstripe-blog)[mostafaznv/php-x-sendfile

Serve large files using web server with support for laravel

5311.3k](/packages/mostafaznv-php-x-sendfile)[silverstripers/seo

SEO for SilverStripe websites

1144.3k](/packages/silverstripers-seo)[bigfork/htmleditorsrcset

Simple srcset integration with SilverStripe’s HTMLEditorField

1025.4k4](/packages/bigfork-htmleditorsrcset)[dorsetdigital/silverstripe-enhanced-requirements

Enhanced requirements for Silverstripe

117.8k4](/packages/dorsetdigital-silverstripe-enhanced-requirements)[silverstripe/multi-domain

Allows multiple domains to access one CMS instance, mapping them to different sections of the hierarchy

141.6k](/packages/silverstripe-multi-domain)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
