Time-expiring signed URLs for protected SilverStripe assets, similar to Amazon S3 pre-signed URLs.
- 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)
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"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.
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).
Add to your .env file:
# File serving method: 'php' (default), 'apache', or 'nginx'
ASSET_FILE_SERVER=phpHow 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.
-
Set
ASSET_FILE_SERVER=nginxin.env -
Add an
internallocation block to your nginx server config (see examples below) -
Run the verification task to confirm your setup:
vendor/bin/sake dev/tasks/SignedAssetUrlVerifyTask
The module derives the location from basename(SS_PROTECTED_ASSETS_PATH):
SS_PROTECTED_ASSETS_PATH |
X-Accel-Redirect header | Required 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).
Both work, but root is recommended — it's more portable:
root— nginx appends the full URI to the root path. Pointrootat 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. Pointaliasat 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 ✗
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.
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;
}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;
}
}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.
-
Install and enable mod_xsendfile:
sudo a2enmod xsendfile sudo systemctl restart apache2
-
Set
ASSET_FILE_SERVER=apachein.env -
Add to your Apache config or
.htaccess:Default setup:
<IfModule mod_xsendfile.c> XSendFile On XSendFilePath /path/to/project/public/assets/.protected </IfModule>
Custom setup (using
SS_PROTECTED_ASSETS_PATH):<IfModule mod_xsendfile.c> XSendFile On XSendFilePath /path/to/project/protected_assets </IfModule>
-
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.
| Method | Pros | Cons |
|---|---|---|
| PHP (default) | No server config needed, works everywhere | Higher memory usage, slower for large files |
| Nginx | Very fast, low memory | Requires nginx config access |
| Apache | Fast, works with .htaccess | Requires mod_xsendfile installation |
For 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
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<!-- Default TTL -->
<a href="$MyFile.SignedURL">Download File</a>
<!-- Custom TTL via AutoURL -->
<a href="$MyFile.AutoURL(7200)">Download File (2hr)</a>
<!-- Using named policies (recommended) -->
<a href="$MyFile.AutoURL('ss')">Download (30s, session-bound)</a>
<a href="$MyFile.AutoURL('m')">Download (1hr)</a>
<a href="$MyFile.AutoURL('ls')">Download (24hr, session-bound)</a>
<!-- Auto-detect (signed if protected, normal if public) -->
<img src="$MyImage.AutoURL" alt="$MyImage.Title">
<!-- Conditional logic based on protection status -->
<% if $MyFile.RequiresSignedURL %>
<a href="$MyFile.SignedURL">Protected Download</a>
<% else %>
<a href="$MyFile.URL">Public Download</a>
<% end_if %>| Method | Description |
|---|---|
$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 protected |
Named policies provide convenient presets for TTL and session binding. Use them in templates for cleaner, more maintainable code.
| Policy | TTL | Session-bound | Use case |
|---|---|---|---|
ss |
30 sec | Yes | Highly sensitive, immediate use only |
s |
30 sec | No | Shareable but very short-lived |
ms |
1 hour | Yes | Sensitive documents, single-user access |
m |
1 hour | No | General protected content, shareable |
ls |
24 hours | Yes | Long-lived user-specific access |
l |
24 hours | No | Long-lived shareable links |
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-boundUsage:
<a href="$Document.AutoURL('download')">Download</a>
<img src="$PreviewImage.AutoURL('preview')">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 signaturee: Unix timestamp when link expiresss: 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.
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.
- URL Generation: PHP generates a signed URL with HMAC hash and expiry timestamp
- Request: User requests the signed URL
- Validation: Controller validates hash and checks expiry
- Session Check: If URL is session-bound, validates against current session
- Admin Check: If user has CMS access, signature validation is bypassed
- Serving: File is streamed via SilverStripe's AssetStore (handles hash-based paths automatically)
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:
- Middleware tracks the earliest expiry time of all signed URLs generated during a request
- Before sending the response, it adjusts
Cache-Control: max-ageto not exceed that expiry - Also sets an
Expiresheader for older HTTP caches
Configuration:
Restruct\SilverStripe\SignedAssetUrls\Services\AssetUrlSigningService:
# Enable/disable automatic cache header adjustment (default: true)
auto_cache_headers: trueExample: 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.
If you manage cache headers yourself or use a CDN with custom rules:
Restruct\SilverStripe\SignedAssetUrls\Services\AssetUrlSigningService:
auto_cache_headers: falseWhen using SilverStripe's partial caching (<% cached %>), 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.
<%-- BAD: URL will become stale when cache outlives TTL --%>
<% cached 'my-cache-key' %>
<a href="$Document.SignedURL">Download</a>
<% end_cached %>If your cache lives longer than the signed URL's TTL, users will get expired links.
1. Include TTL window in cache key:
<%-- Cache key changes every hour (matches default 3600s TTL) --%>
<% cached 'document', $Document.ID, $Now.Format('Y-m-d-H') %>
<a href="$Document.SignedURL">Download</a>
<% end_cached %>2. Set cache lifetime shorter than TTL:
<%-- Cache expires before signed URL does --%>
<% cached 'document', $Document.ID, 1800 %>
<a href="$Document.AutoURL('md')">Download</a>
<% end_cached %>3. Exclude signed URLs from cached blocks:
<% cached 'page-content', $ID, $LastEdited %>
<h1>$Title</h1>
$Content
<% end_cached %>
<%-- Outside cache block --%>
<a href="$Document.SignedURL">Download Document</a>4. Use session-bound URLs with uncached blocks:
<% cached 'page-content' %>
<h1>$Title</h1>
<% end_cached %>
<% uncached %>
<%-- Session-bound URLs should never be cached anyway --%>
<a href="$Document.AutoURL('md_sess')">Download</a>
<% end_uncached %>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);
}<% cached 'downloads', $ID, $SignedURLCacheWindow(3600) %>
<a href="$Document.SignedURL">Download</a>
<% end_cached %>Never cache session-bound URLs - they are unique per user session:
<%-- BAD: Will serve one user's session-bound URL to everyone --%>
<% cached 'document' %>
<a href="$Document.AutoURL('md_sess')">Download</a>
<% end_cached %>
<%-- GOOD: Always uncached --%>
<% uncached %>
<a href="$Document.AutoURL('md_sess')">Download</a>
<% end_uncached %>SilverStripe's asset system stores files in either a public or protected folder based on:
- CanViewType permissions: Restricted files (LoggedInUsers, OnlyTheseUsers, or inherited from parent)
- Versioned staging: Unpublished files when using full Versioned extension
This module respects both protection mechanisms.
Protection is checked at two levels:
When you call $file->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 insteadThis uses SilverStripe's built-in hasRestrictedAccess() method which handles CanViewType checking including parent folder inheritance.
When a signed URL is accessed, the controller validates:
- Signature validity: HMAC hash matches and hasn't expired
- Session binding: If URL is session-bound, validates current session
- Published status (optional): If
check_published_statusis enabled, denies access to unpublished files
CMS users with bypass_permissions can always access any file.
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.versionedIn this case:
isPublished()always returns true (no staging = always "published")- Files are only protected based on CanViewType permissions
- You can disable
check_published_statusfor 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- Keep your secret safe: The
ASSET_SIGNING_SECRETshould 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)
A BuildTask is included to verify your configuration and test URL generation/validation:
vendor/bin/sake dev/tasks/SignedAssetUrlVerifyTaskThis task will:
- Check that
ASSET_SIGNING_SECRETis configured - Show the protected folder path and whether it exists
- Display current configuration values (TTL, session binding, cache headers)
- Generate a test signed URL and validate its components
- Test signature validation (valid, invalid, and expired cases)
- 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
After making changes to the controller or signing service, verify the following:
vendor/bin/sake dev/build flush=1
vendor/bin/sake dev/tasks/SignedAssetUrlVerifyTaskBoth should complete without errors. The verify task checks signing secret, protected folder path, URL generation, and signature validation (valid, invalid, expired).
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"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
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 pathThe signed URL for that file should work regardless of which layout exists on disk.
If ASSET_FILE_SERVER is set to apache or nginx:
- Verify files are served without PHP streaming (check response headers for
X-SendfileorX-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
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.