Token authentication
Token authentication makes a zone serve only links your own application has signed. Each link carries the time it expires at, so a link that is shared on stops working by itself, and a request without a valid signature is refused with 403.
Caching, slicing, byte ranges, the origin shield and the statistics behave exactly as on any other zone: only the link is checked, and the signature never reaches your origin or the cache key.
Turning it on#
The zone's URL Token Authentication Key is the shared secret; keep it on your server and never ship it to a browser or an app. Sign your links before you switch Require Signed Links on, because from the moment it is on an unsigned request to the zone is a 403, and every location picks the change up within seconds. Every zone has a key whether the switch is on or not, but nothing checks a token until it is, so test a signed link on a zone with the switch on. New key issues a fresh one and stops every link already signed, so keep it for the day the key leaks.
How a token is built#
A token is an HMAC-SHA256 of four lines, encoded with the URL-safe base64 alphabet and no padding:
token = "CG2-" + base64url( HMAC_SHA256(key, message) )
message = "CG2" LF <mode> LF <what is being signed> LF <expires>- CG2: the name of this scheme. It is inside the signed message as well as on the front of the token, so a future scheme can never be mistaken for this one and links you sign today keep working when one arrives.
- mode: file when the link signs one file, folder when it signs a folder. It is inside the message, so a link for one file cannot be replayed as a folder link that opens everything beneath it.
- what is being signed: the path of the file, such as /vod/intro.mp4. When you sign a folder instead (see below) this is the folder.
- expires: a Unix time in seconds, as digits. There is no grace period; the moment it passes, the link is refused.
The key is the 64 characters shown in the NOC, used as they are and not decoded from hex, and the message is the four parts joined with a line feed and nothing else. Sign the path as it names the file, not as it is written in the URL: /vod/my film.mp4, not /vod/my%20film.mp4. The snippets below decode it for you. A link then keeps working whichever way a player chooses to encode it.
The hostname is not part of the signature, so a signed link works on every hostname of the zone. Nor is the query string: three parameters are ours, token, expires and token_path, and every other parameter is ignored by the check and passed through to your origin untouched (a private bucket never receives the query string at all), so a player that appends its own cache buster or session id cannot break a link.
Signing a link#
<?php
function cg_sign_url($url, $key, $seconds = 3600)
{
$parts = parse_url($url);
$expires = time() + $seconds;
$message = "CG2\nfile\n".rawurldecode($parts['path'])."\n".$expires;
$mac = hash_hmac('sha256', $message, $key, true);
$token = "CG2-".rtrim(strtr(base64_encode($mac), '+/', '-_'), '=');
$join = isset($parts['query']) ? '&' : '?';
return $url.$join."token=".$token."&expires=".$expires;
}
echo cg_sign_url("https://videos.example.com/intro.mp4", $CG_TOKEN_KEY);const crypto = require("crypto");
function cgSignUrl(url, key, seconds = 3600) {
const u = new URL(url);
const expires = Math.floor(Date.now() / 1000) + seconds;
const message = `CG2\nfile\n${decodeURIComponent(u.pathname)}\n${expires}`;
const mac = crypto.createHmac("sha256", key).update(message).digest("base64url");
u.searchParams.set("token", "CG2-" + mac);
u.searchParams.set("expires", String(expires));
return u.toString();
}
const url = "https://videos.example.com/intro.mp4";
console.log(cgSignUrl(url, process.env.CG_TOKEN_KEY));import base64, hashlib, hmac, time
from urllib.parse import urlparse, unquote, urlencode
def cg_sign_url(url, key, seconds=3600):
parts = urlparse(url)
expires = int(time.time()) + seconds
message = "CG2\nfile\n%s\n%d" % (unquote(parts.path), expires)
mac = hmac.new(key.encode(), message.encode(), hashlib.sha256).digest()
token = "CG2-" + base64.urlsafe_b64encode(mac).decode().rstrip("=")
join = "&" if parts.query else "?"
return url + join + urlencode({"token": token, "expires": expires})
print(cg_sign_url("https://videos.example.com/intro.mp4", CG_TOKEN_KEY))The result looks like this:
https://videos.example.com/intro.mp4?token=CG2-hP3rXnQ8yv2K...&expires=1789459200How long a link should live#
Longer than the whole viewing, pauses included: a player that reconnects after the expiry gets a 403 in the middle of playback rather than a stall it can recover from. Sign the link when the viewer asks to watch rather than when you build the catalogue page, since a link works for anyone who holds it until it expires.
HLS and DASH: signing a folder#
A player builds each segment's address from the playlist's own folder and drops the query string on the way, so with a playlist signed in the form above the playlist loads and every segment request is refused. For streaming, sign the folder and put the token in the path, where the player will carry it along:
https://videos.example.com/cg_token=CG2-hP3r...&expires=1789459200&token_path=%2Fvod%2Fep12%2F/vod/ep12/play.m3u8The first path segment starts with cg_token= and carries the same three parameters, with the values percent-encoded. What follows is the ordinary path of the file. One signature then covers the playlist and every segment beside it.
<?php
function cg_sign_folder($base, $folder, $file, $key, $seconds = 3600)
{
$expires = time() + $seconds;
$message = "CG2\nfolder\n".$folder."\n".$expires;
$mac = hash_hmac('sha256', $message, $key, true);
$token = "CG2-".rtrim(strtr(base64_encode($mac), '+/', '-_'), '=');
$seg = "/cg_token=".$token."&expires=".$expires
."&token_path=".rawurlencode($folder);
return $base.$seg.$file;
}
echo cg_sign_folder(
"https://videos.example.com", "/vod/ep12/", "/vod/ep12/play.m3u8",
$CG_TOKEN_KEY, 14400
);Three things to get right:
- Sign the folder, not the playlist. The signature covers whatever you put in token_path, and the file requested has to sit inside it.
- End the folder with a slash. /vod/ep12/ and /vod/ep12 are different strings and sign differently. A token for /vod/ep12 never opens /vod/ep120 either way, but token_path must be byte for byte what you signed, so pick one spelling and keep to it.
- Percent-encode the values. They are decoded the way a query string is, so a plus sign reads as a space: encode a space as %20, which rawurlencode and encodeURIComponent both do.
Give a stream long enough to finish: a four hour window costs nothing and outlives any pause.
Why a link was refused#
A refused request is a 403: a page on your hostname saying the link is no longer valid, or one line of plain text for a player, and it is never cached. The page says the same words whichever check failed; the reason is in the X-CDN-Token response header, so one request tells you what is wrong:
curl -sI "https://videos.example.com/intro.mp4?token=CG2-...&expires=..." \
| grep -i x-cdn-tokenmissingReasonthe URL carried no token at all. Usually a link built somewhere that has not been updated yet, or a player that dropped the query string, which is what the folder form above is for.malformedReasona token or an expiry arrived but could not be read. An empty token, an expiry that is not a number, a signature written by code that never ran, or a token from an older scheme: anything that does not begin CG2- reads as malformed.bad-signatureReasonthe signature does not match. The usual causes are the wrong key, a path that does not match what was signed, a link for one file presented as a folder link (the mode is signed too), or a link that has been edited: changing the expiry to buy more time lands here too.expiredReasonthe signature is genuinely ours and its time has passed. Sign a new link, and consider a longer window.path-mismatchReasona valid folder token used for a file outside the folder it signed. Check token_path and its trailing slash.
If everything looks right and you still see bad-signature, print the message your code hashes and compare it byte for byte with the layout above. Almost every case is a missing line feed, a path signed with the host still attached, or an expiry that was turned into a string with a decimal point.
Managing it from the API#
The zone object carries TOKEN_AUTH as 1 or 0, and you can set it when you create or update a zone. The signing key has an endpoint of its own so that listing your zones never returns a secret:
https://api.cachegenie.com/v1/cdn/{ZONE_ID}/token-keyhttps://api.cachegenie.com/v1/cdn/{ZONE_ID}/token-keyGET returns the key, POST issues a new one. See Signing keys for the request and response in full.
What token authentication does not do#
- It does not tie a link to one viewer. Anyone holding the link can use it until it expires, so keep the window short.
- It does not protect your origin. Anyone who can reach your origin directly still can; keep it closed to the public, or use a private bucket origin.
- It does not exempt certificate challenges. A Let's Encrypt validation for a certificate on your own web server, arriving at a signed zone under /.well-known/acme-challenge/, is refused like any other unsigned request. Renew that certificate on a hostname that is not on a signed zone, or with a DNS challenge.