Signed URLs
A signed URL carries a short-lived MD5 token that the edge checks before serving. Without a valid, unexpired token the request is 403'd. This is token-gated delivery — it is not per-viewer DRM, and it does not encrypt the bytes. Use it for HLS/DASH streams and paid or otherwise gated objects.
Configure it on the resource's Security tab. Integrators can implement a signer from this page alone.
Quick start
- Open the edge resource → Security.
- Enable Signed URLs, generate a secret, pick Path for streaming (one token covers a playlist and its same-directory segments).
- Sign a URL with the tester widget on that tab, or with the reference implementations below.
- Confirm the unsigned path returns 403 and the signed URL returns 200.
Unsigned URLs start 403ing fleet-wide within about a minute of save, on every PoP running a bundle that includes this feature. Upgrade the fleet first if any PoP is still on an older release (older generators ignore the field — no breakage, but no protection).
Token placement: parameter vs path
| Parameter | Path | |
|---|---|---|
| URL shape | /file/video.mp4?secure=HASH[,expiry] | /HASH[,expiry]/file/playlist/d.m3u8 |
| What is hashed | Full decoded file path | dirname(filePath) (/file/playlist/d.m3u8 → /file/playlist) |
| HLS/DASH | Each object needs its own token | One token covers a playlist and its same-directory segments |
| IP lock | Not available | off / optional / required |
Path is required for live and VOD streaming. A root-level file (/d.m3u8)
hashes as /.
The algorithm
Hash input, concatenated with no separators except the documented space in the IP variant:
[expiry] + hashedPath + [ip + " "] + secret
hashedPathis the decoded URI path with the query string stripped. For path type it isdirname(filePath); empty dirname becomes/.expiryis unix seconds. Optional unless Require expiry is on.- Hash =
base64(md5_raw(input))with+→-,/→_. Padding (==) is optional on input; generators typically keep it.
Worked examples — use these to unit-test a signer:
| Variant | Input | Hash |
|---|---|---|
| parameter | 1389183132/file/video.mp4ykX1QNTRvp3tfSn8 | 29QpicPWKD6RpuYMfC8LfA== |
| path | 1389183132/file/playlistykX1QNTRvp3tfSn8 | z--FA_CsNsR2TOV2eg9q4w== |
| path + IP | 1617203518/live1.2.3.4 sauhc8s2jscks | Iw_QFL8Z9c09tOeZTqUUsg== |
Resulting URLs:
https://cdn.example.com/file/video.mp4?secure=29QpicPWKD6RpuYMfC8LfA==,1389183132
https://cdn.example.com/z--FA_CsNsR2TOV2eg9q4w==,1389183132/file/playlist/d.m3u8
https://cdn.example.com/Iw_QFL8Z9c09tOeZTqUUsg==,1617203518/live/playlist.m3u8
Signing reference implementations
TypeScript / Node
import { createHash } from 'node:crypto';
function md5Url(input: string): string {
return createHash('md5')
.update(input)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
export function signSecureUrl(opts: {
host: string;
filePath: string;
secret: string;
type: 'parameter' | 'path';
expiry?: number;
ip?: string;
queryParam?: string;
}): string {
let filePath = opts.filePath.startsWith('/') ? opts.filePath : `/${opts.filePath}`;
const q = filePath.indexOf('?');
if (q >= 0) filePath = filePath.slice(0, q);
const hashedPath =
opts.type === 'path'
? filePath.slice(0, filePath.lastIndexOf('/')) || '/'
: filePath;
let input = hashedPath + opts.secret;
if (opts.ip) input = hashedPath + opts.ip + ' ' + opts.secret;
if (opts.expiry != null) input = String(opts.expiry) + input;
const hash = md5Url(input);
const exp = opts.expiry != null ? `,${opts.expiry}` : '';
if (opts.type === 'path') {
return `https://${opts.host}/${hash}${exp}${filePath}`;
}
const param = opts.queryParam ?? 'secure';
return `https://${opts.host}${filePath}?${param}=${hash}${exp}`;
}
PHP
function signSecureUrl(
string $host,
string $filePath,
string $secret,
string $type = 'parameter',
?int $expiry = null,
?string $ip = null,
string $queryParam = 'secure'
): string {
if ($filePath[0] !== '/') {
$filePath = '/' . $filePath;
}
if ($q = strpos($filePath, '?')) {
$filePath = substr($filePath, 0, $q);
}
$hashedPath = $type === 'path'
? (substr($filePath, 0, strrpos($filePath, '/')) ?: '/')
: $filePath;
$input = $hashedPath . $secret;
if ($ip) {
$input = $hashedPath . $ip . ' ' . $secret;
}
if ($expiry !== null) {
$input = $expiry . $input;
$expirySuffix = ',' . $expiry;
} else {
$expirySuffix = '';
}
$hash = str_replace(['+', '/'], ['-', '_'], base64_encode(md5($input, true)));
if ($type === 'path') {
return 'https://' . $host . '/' . $hash . $expirySuffix . $filePath;
}
return 'https://' . $host . $filePath . '?' . $queryParam . '=' . $hash . $expirySuffix;
}
Python
import base64, hashlib
def sign_secure_url(host, file_path, secret, type="parameter", expiry=None, ip=None, query_param="secure"):
if not file_path.startswith("/"):
file_path = "/" + file_path
file_path = file_path.split("?", 1)[0]
hashed_path = (file_path.rsplit("/", 1)[0] or "/") if type == "path" else file_path
payload = hashed_path + secret
if ip:
payload = hashed_path + ip + " " + secret
if expiry is not None:
payload = str(expiry) + payload
digest = hashlib.md5(payload.encode()).digest()
token = base64.b64encode(digest).decode().replace("+", "-").replace("/", "_")
exp = f",{expiry}" if expiry is not None else ""
if type == "path":
return f"https://{host}/{token}{exp}{file_path}"
return f"https://{host}{file_path}?{query_param}={token}{exp}"
Sign the decoded path (the same form the edge hashes). Do not sign a percent-encoded URI.
IP lock
Path type only. Parameter type must stay off.
| Mode | Behaviour |
|---|---|
off | Hash never includes the client IP |
optional (path default) | Accept both IP-locked and plain hashes |
required | Hash must include the request's remote address |
NAT / IPv6 privacy.
requiredbreaks when the address the signer saw is not the address the edge sees — typical behind CGNAT and with IPv6 temporary addresses. Preferoptionalunless you control both ends.
Interaction with caching and origins
The token never fragments the cache:
- parameter — the token query param is dropped from the cache key (even when query-string normalization is otherwise off). Two users' differently signed URLs share one cache entry. The original query string is still forwarded to origin.
- path — a rewrite strips
/HASH[,expiry]before location matching, so cache keys ($uri) and origin (proxy_pass) see the clean path.
Purge URLs are always unsigned. Do not put a token on a PURGE request.
Path type cannot be combined with an origin-passthrough upstream or a custom
cache key that references $request_uri — both would keep the token prefix
and either 404 at origin or fragment the cache. The validator rejects those
combinations (ST6 / ST7).
Exemptions and per-route opt-out
Always exempt (never 403'd by this gate):
- Built-in endpoints:
/oc-cgi/*,/.well-known/acme-challenge/,/nginx_status PURGE(has its own IP ACL)- Internal fill traffic (
OC-Peer-Filland unix-socket tier hops)
A content-serving route can opt out with routes[].secureToken: false —
the Require signed URL toggle on the Routes tab, shown only when the
site-level feature is on. Use it for a public favicon or similar asset on
the same domain.
Errors and troubleshooting
All rejections are 403. The distinction is in the access-log
cache_rule field:
| Marker | Meaning |
|---|---|
secure-token:none | Token missing or unparseable |
secure-token:bad | Hash does not match, or require-expiry and no expiry |
secure-token:expired | Expiry is in the past |
Common signer mistakes:
- Signing the percent-encoded path instead of the decoded path
- Missing leading
/ - Hashing the full path instead of
dirnamefor path type - Expiry in milliseconds instead of unix seconds
- Including the filename in a path-type hash (chunks then 403)
Where the validator catches mistakes
| Rule | Check |
|---|---|
| ST1 | Enabled requires a non-empty secret |
| ST2 | Secret is 8–128 printable ASCII, no whitespace (warn below 16) |
| ST3 | Type is parameter or path |
| ST4 | queryParam matches [A-Za-z0-9_.-]{1,32}; unused (warned) on path type |
| ST5 | Parameter type cannot set ipLock to anything but off |
| ST6 | Path type cannot protect an origin-passthrough route |
| ST7 | Path type cannot use a $request_uri cache key |
| ST8 | routes[].secureToken: false while the site feature is off (warning) |
Further reading
- OpenCache Runtime Behavior — cache keys, diagnostics, peer fill
- OpenCache — architecture overview
