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

  1. Open the edge resource → Security.
  2. Enable Signed URLs, generate a secret, pick Path for streaming (one token covers a playlist and its same-directory segments).
  3. Sign a URL with the tester widget on that tab, or with the reference implementations below.
  4. 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

ParameterPath
URL shape/file/video.mp4?secure=HASH[,expiry]/HASH[,expiry]/file/playlist/d.m3u8
What is hashedFull decoded file pathdirname(filePath) (/file/playlist/d.m3u8 → /file/playlist)
HLS/DASHEach object needs its own tokenOne token covers a playlist and its same-directory segments
IP lockNot availableoff / 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:

text
[expiry] + hashedPath + [ip + " "] + secret
  • hashedPath is the decoded URI path with the query string stripped. For path type it is dirname(filePath); empty dirname becomes /.
  • expiry is 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:

VariantInputHash
parameter1389183132/file/video.mp4ykX1QNTRvp3tfSn829QpicPWKD6RpuYMfC8LfA==
path1389183132/file/playlistykX1QNTRvp3tfSn8z--FA_CsNsR2TOV2eg9q4w==
path + IP1617203518/live1.2.3.4 sauhc8s2jscksIw_QFL8Z9c09tOeZTqUUsg==

Resulting URLs:

text
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

ts
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

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

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.

ModeBehaviour
offHash never includes the client IP
optional (path default)Accept both IP-locked and plain hashes
requiredHash must include the request's remote address

NAT / IPv6 privacy. required breaks when the address the signer saw is not the address the edge sees — typical behind CGNAT and with IPv6 temporary addresses. Prefer optional unless 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-Fill and 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:

MarkerMeaning
secure-token:noneToken missing or unparseable
secure-token:badHash does not match, or require-expiry and no expiry
secure-token:expiredExpiry 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 dirname for 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

RuleCheck
ST1Enabled requires a non-empty secret
ST2Secret is 8–128 printable ASCII, no whitespace (warn below 16)
ST3Type is parameter or path
ST4queryParam matches [A-Za-z0-9_.-]{1,32}; unused (warned) on path type
ST5Parameter type cannot set ipLock to anything but off
ST6Path type cannot protect an origin-passthrough route
ST7Path type cannot use a $request_uri cache key
ST8routes[].secureToken: false while the site feature is off (warning)

Further reading