Caddy Signed URL Plugin
A Caddy HTTP handler plugin that validates HMAC-SHA256 signed URLs. Perfect for creating time-limited, secure access to protected resources like file downloads, API endpoints, or any content that requires temporary authorization.
Features
- 🔐 HMAC-SHA256 signature verification - Industry-standard cryptographic signing
- ⏰ Optional expiration - Create time-limited URLs that automatically expire
- 📝 Audit logging - Track when URLs were issued and accessed
- 🔄 Query parameter ordering - Immune to parameter reordering attacks
- 🎯 Flexible configuration - Support for query parameters and HTTP headers
- 🚀 High performance - Minimal overhead with constant-time signature comparison
Installation
Build Caddy with this plugin using xcaddy:
xcaddy build --with github.com/hookenz/caddy-signedurls
Or add it to your go.mod:
go get github.com/hookenz/caddy-signedurls
Quick Start
Basic Usage
Protect file server routes with signed URLs:
example.com {
route /downloads/* {
signed_url "your-secret-key-here"
file_server {
root /var/www/downloads
}
}
}
With Expiration
Create URLs that expire after 1 hour:
example.com {
route /shared/* {
signed_url "your-secret-key-here"
file_server {
root /var/www/shared
}
}
}
Configuration
Inline Syntax
signed_url "your-secret-key"
Block Syntax
signed_url {
secret "your-secret-key" # Required
algorithm "sha265" # Optional, default: "sha256", options: sha265, sha384, sha512
query_param "signature" # Optional, default: "signature"
header "X-Signature" # Optional, default: "X-Signature"
expires_param "expires" # Optional, default: "expires"
issued_param "issued" # Optional, default: "issued"
}
Configuration Options
| Option |
Type |
Default |
Description |
secret |
string |
(required) |
Secret key for HMAC signing |
algorithm |
string |
sha256 |
The signature algorithm used by the signer |
query_param |
string |
signature |
Query parameter name for signature |
header |
string |
X-Signature |
HTTP header name for signature |
expires_param |
string |
expires |
Query parameter name for expiration timestamp |
issued_param |
string |
issued |
Query parameter name for issued timestamp |
Generating Signed URLs
Python
import hmac
import hashlib
import time
from urllib.parse import urlencode
secret = "your-secret-key"
path = "/downloads/document.pdf"
issued = int(time.time())
expires = issued + 3600 # Valid for 1 hour
# Build query parameters (automatically sorted)
params = {
'issued': issued,
'expires': expires
}
query_string = urlencode(sorted(params.items()))
# Sign the path with sorted query parameters
to_sign = f"{path}?{query_string}"
signature = hmac.new(
secret.encode(),
to_sign.encode(),
hashlib.sha256
).hexdigest()
# Final URL
url = f"{path}?{query_string}&signature={signature}"
print(url)
# /downloads/document.pdf?expires=1731632400&issued=1731628800&signature=abc123...
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"time"
)
func generateSignedURL(secret, path string, ttl time.Duration) string {
issued := time.Now().Unix()
expires := issued + int64(ttl.Seconds())
// Build query with sorted params
query := url.Values{}
query.Set("issued", fmt.Sprintf("%d", issued))
query.Set("expires", fmt.Sprintf("%d", expires))
// Sign the path with query params
toSign := path + "?" + query.Encode()
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(toSign))
signature := hex.EncodeToString(h.Sum(nil))
// Add signature to query
query.Set("signature", signature)
return path + "?" + query.Encode()
}
func main() {
secret := "your-secret-key"
path := "/downloads/document.pdf"
url := generateSignedURL(secret, path, 1*time.Hour)
fmt.Println(url)
}
Node.js
const crypto = require('crypto');
function generateSignedURL(secret, path, ttlSeconds) {
const issued = Math.floor(Date.now() / 1000);
const expires = issued + ttlSeconds;
// Build query params (URLSearchParams sorts automatically)
const params = new URLSearchParams();
params.set('issued', issued);
params.set('expires', expires);
params.sort();
// Sign the path with query params
const toSign = `${path}?${params.toString()}`;
const signature = crypto
.createHmac('sha256', secret)
.update(toSign)
.digest('hex');
// Add signature to query
params.set('signature', signature);
return `${path}?${params.toString()}`;
}
const url = generateSignedURL('your-secret-key', '/downloads/document.pdf', 3600);
console.log(url);
PHP
<?php
function generateSignedURL($secret, $path, $ttl) {
$issued = time();
$expires = $issued + $ttl;
// Build query params
$params = [
'issued' => $issued,
'expires' => $expires
];
// Sort params for consistent signature
ksort($params);
$queryString = http_build_query($params);
// Sign the path with query params
$toSign = $path . '?' . $queryString;
$signature = hash_hmac('sha256', $toSign, $secret);
// Add signature to query
$params['signature'] = $signature;
ksort($params);
return $path . '?' . http_build_query($params);
}
$url = generateSignedURL('your-secret-key', '/downloads/document.pdf', 3600);
echo $url;
?>
Usage Examples
Protecting File Downloads
files.example.com {
route /secure/* {
signed_url "super-secret-key"
file_server {
root /var/www/secure-files
}
}
# Public files don't need signing
route /public/* {
file_server {
root /var/www/public-files
}
}
}
Protecting API Endpoints
api.example.com {
route /api/private/* {
signed_url "api-secret-key"
reverse_proxy localhost:8080
}
route /api/public/* {
reverse_proxy localhost:8080
}
}
Custom Parameter Names
cdn.example.com {
route /media/* {
signed_url {
secret "cdn-secret"
query_param "sig"
expires_param "exp"
issued_param "iat"
}
file_server {
root /var/www/media
}
}
}
For APIs where you don't want signatures in URLs:
api.example.com {
route /api/internal/* {
signed_url {
secret "internal-api-key"
header "X-API-Signature"
}
reverse_proxy localhost:9000
}
}
Client usage:
curl -H "X-Signature: abc123..." https://api.example.com/api/internal/data
How It Works
Signature Validation Process
- Extract signature - Check query parameter or HTTP header
- Parse timestamps - Validate
issued and expires if present
- Check expiration - Reject if URL has expired
- Build signed string - Reconstruct path + sorted query params (minus signature)
- Calculate HMAC - Generate expected signature using secret key
- Compare - Use constant-time comparison to prevent timing attacks
- Allow or deny - Return 200 OK or 401 Unauthorized
What Gets Signed
The signature covers:
- The full request path (e.g.,
/downloads/file.pdf)
- All query parameters except
signature itself
- Query parameters are automatically sorted alphabetically
Example:
Original URL: /file?expires=123&issued=456&signature=abc
Signed string: /file?expires=123&issued=456
Query Parameter Ordering
The plugin automatically handles parameter reordering. These URLs all produce the same signature:
/file?expires=123&issued=456&signature=abc
/file?issued=456&expires=123&signature=abc
/file?signature=abc&expires=123&issued=456
Security Considerations
Secret Key Management
- Use strong secrets - Minimum 32 characters, randomly generated
- Rotate regularly - Change secrets periodically
- Keep secrets secure - Never commit to version control
- Use environment variables - Store secrets in environment or secret management systems
{
# Load secret from environment
signed_url {
secret {$SIGNED_URL_SECRET}
}
}
Expiration Times
- Short TTLs recommended - 1 hour or less for sensitive content
- Balance security and usability - Consider your use case
- No default expiration - Must explicitly set
expires parameter
HTTPS Required
Always use HTTPS in production to prevent signature interception:
example.com {
# Caddy automatically handles HTTPS
route /downloads/* {
signed_url "secret"
file_server
}
}
Logging
The plugin uses structured logging with different levels:
INFO (Startup)
{
"level": "info",
"logger": "http.handlers.signed_url",
"msg": "signed_url handler provisioned",
"query_param": "signature",
"header": "X-Signature",
"expires_param": "expires",
"issued_param": "issued"
}
WARN (Authentication Failures)
{
"level": "warn",
"logger": "http.handlers.signed_url",
"msg": "invalid signature",
"path": "/downloads/file.pdf",
"signature_source": "query",
"remote_addr": "192.168.1.100:54321"
}
DEBUG (Successful Validations)
{
"level": "debug",
"logger": "http.handlers.signed_url",
"msg": "signature validated successfully",
"path": "/downloads/file.pdf",
"signature_source": "query",
"issued_at": 1731628800,
"age_seconds": 450,
"expires_at": 1731632400,
"ttl_seconds": 3150
}
Enable debug logging:
caddy run --config Caddyfile --debug
Or in Caddyfile:
{
log {
level DEBUG
}
}
Testing
Run the test suite:
# All tests
go test -v
# Specific test
go test -v -run TestSignedURL_QueryParamOrdering
# With coverage
go test -cover
# Benchmarks
go test -bench=.
Error Responses
| Status Code |
Message |
Cause |
| 400 Bad Request |
Invalid expires parameter |
Expires is not a valid Unix timestamp |
| 400 Bad Request |
Invalid issued parameter |
Issued is not a valid Unix timestamp |
| 401 Unauthorized |
Missing signature |
No signature in query param or header |
| 401 Unauthorized |
Invalid signature |
Signature verification failed |
| 401 Unauthorized |
URL has expired |
Current time is past expiration |
The plugin is designed for high performance:
- Constant-time comparison - Prevents timing attacks
- Minimal allocations - Efficient memory usage
- Early validation - Checks expiration before signature calculation
- No external dependencies - Uses standard library crypto
Benchmark results (example):
BenchmarkSignedURL_ValidSignature-8 500000 3421 ns/op
Troubleshooting
Common Issues
1. "Invalid signature" errors
- Check that the secret key matches on both sides
- Verify query parameters are sorted alphabetically when signing
- Ensure the path includes query parameters in the signature
- Check that signature is excluded from the signed string
2. "URL has expired"
- Verify system clocks are synchronized (use NTP)
- Check timezone is not affecting Unix timestamp generation
- Ensure TTL is appropriate for your use case
3. "Missing signature"
- Verify signature parameter name matches configuration
- Check if using header-based signatures, the header name is correct
- Ensure signature is being passed in the request
Debug Tips
- Enable debug logging to see successful validations
- Log the signed string on both client and server
- Test with curl to isolate client issues:
curl -v "https://example.com/file?expires=123&signature=abc"
- Use online HMAC calculators to verify signature generation
Contributing
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass
- Submit a pull request
License
MIT License
- Caddy - The web server this plugin extends
- xcaddy - Build tool for Caddy with plugins
Support
Changelog
v0.1.0
- Initial release
- HMAC-SHA256 signature validation
- Optional expiration support
- Issued timestamp for audit logging
- Query parameter and header support
- Test suite