Access control is where CDN security stops being theoretical and starts hitting revenue, compliance, and support tickets. If you run video, software downloads, or any premium content behind a CDN, an unprotected URL is a liability the moment it gets shared, scraped, or indexed. I've spent enough late nights tracing leaked URLs and geo-bypass attempts to know that a simple "private bucket + obscure path" setup does not survive contact with real users. Proper access control at the edge means making the CDN itself enforce who can fetch what, for how long, and from where — without adding a round-trip to origin for every request. This article covers the three mechanisms I use on almost every secure delivery setup: signed URLs, token authentication, and geo-restrictions, plus how to combine them without creating a fragile, undebuggable mess.
Why Your CDN Needs More Than Just Obscure URLs
In my experience, most teams start with unlisted URLs and assume that's enough. It works until someone shares a link in Slack, a browser extension logs it, or a search crawler follows it. CDNs are built to cache and serve fast, which also means they will happily serve that content to anyone who knows the path unless you explicitly tell the edge to check.
Access control at the edge solves a different problem than origin security. Origin protection techniques like those described in Origin Protection: IP Whitelisting, Authenticated Pulls and Origin Cloaking keep attackers from bypassing the CDN and hitting your origin directly. Edge access control keeps unauthorized end users from using the CDN itself to fetch content they shouldn't have. You need both. If the CDN is the only public entry point, then the CDN must be the policy enforcement point.
The Cost of Doing Nothing at the Edge
Without edge validation, every asset you cache is effectively public. I've audited sites where high-value PDFs, HLS master playlists, and even licensed video segments were cacheable with a 1-year TTL and no validation. One customer sharing a single URL in a forum meant thousands of unauthenticated hits served from cache, never touching origin logging, so nobody noticed for weeks. The fix isn't to disable caching — it's to make the cache key and delivery conditional on a valid credential.
Where Access Control Fits in the CDN Security Stack
Think of edge access control as the layer between transport security and application logic. TLS and certificate automation covered in SSL/TLS Certificate Management for CDNs: Automation, Renewal and CT Logs gets you encryption, and your application decides who should have access. The CDN sits in the middle and enforces that decision at scale. Unlike a WAF Rules at the CDN Edge: OWASP Core Rule Set and Custom Policies which blocks malicious payloads, access control blocks legitimate-looking requests that simply lack the right proof. The proof is usually a cryptographic signature, a short-lived token, or a geographic assertion — or all three.
How Signed URLs Enforce Time-Bound Access at the Edge
A signed URL is a normal URL with additional query parameters that prove it was issued by someone holding a secret key. The CDN verifies the signature on every request at the edge, before it even checks cache. If the signature is missing, invalid, or expired, the request gets a 403. No origin involvement needed.
I've found that the simplest model holds up best: HMAC-SHA256 over a canonical string, base64url-encoded, with an explicit expiry. Don't try to invent your own signing scheme. Use what your CDN documents and what libraries already support.
Anatomy of a Signed URL
A typical signed URL contains four components:
1. The resource path — either an exact file like /videos/teaser-1080p.mp4 or a prefix like /videos/* for adaptive streaming where one signature should cover many segments.
2. An expiry timestamp (exp) — Unix epoch seconds. I keep this short: 5-15 minutes for downloads, 60-120 seconds for live HLS segments. Long expiries defeat the purpose.
3. Optional start time (nbf) and IP binding — useful for pre-signed links that shouldn't be valid immediately, or for locking a link to the requester's IP.
4. The signature and key identifier — signature and key_id so you can rotate keys without invalidating everything at once.
The canonical string is usually something like URL + exp + ip + key_id. Order matters. Even a single character difference in whitespace or URL encoding will break validation. The IETF recommendations for HTTP signature handling and URL canonicalization are a good reference if you want to understand why determinism matters here — see the IETF Datatracker (https://datatracker.ietf.org/) for current drafts on HTTP message signatures.
# Python example: generating a signed URL with HMAC-SHA256
import hmac
import hashlib
import base64
import time
from urllib.parse import quote
SECRET = b'your-primary-signing-key-32-bytes'
KEY_ID = 'k2024-11'
BASE_URL = 'https://cdn.example.com/videos/premium/intro.mp4'
def base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
def sign_url(path: str, expires_in: int = 600, client_ip: str = None) -> str:
exp = int(time.time()) + expires_in
# Canonical string: must match exactly what the edge will reconstruct
canonical = f"{path}:{exp}"
if client_ip:
canonical += f":{client_ip}"
canonical += f":{KEY_ID}"
sig = hmac.new(SECRET, canonical.encode('utf-8'), hashlib.sha256).digest()
sig_b64 = base64url_encode(sig)
url = f"{BASE_URL}?exp={exp}&key_id={KEY_ID}&sig={sig_b64}"
if client_ip:
url += f"&ip={client_ip}"
return url
# Example: 10 minute link, IP-locked
print(sign_url("/videos/premium/intro.mp4", 600, "203.0.113.45"))
HMAC Validation at the Edge vs at Origin
Always validate at the edge if your CDN supports it. CloudFront Functions, Cloudflare Workers, Fastly VCL, Akamai EdgeWorkers — they can all run HMAC verification in under a millisecond without contacting origin. Origin validation is a fallback, not a strategy. If you validate only at origin, you still cache the object once, and subsequent requests for the same cache key will be served without revalidation unless you use Vary or token-aware cache keys.
The pattern I recommend: validate signature at edge, then include the signature parameters in the cache key or, better, normalize the cache key to the unsigned path but only serve from cache if validation passed. Most CDNs let you do this with a custom cache key rule. That way you get one cached copy per asset, but no unauthorized user can retrieve it. Check your provider's docs for "signed URL cache key" behavior — it varies. The HTTP Working Group Specifications (https://httpwg.org/specs/) on caching and Vary headers are useful background for understanding why cache key design is critical here.
Token Authentication: Moving From URL Parameters to Cookies and Headers
Signed URLs work well for single files, but they fall apart for adaptive streaming. An HLS master playlist references 50+ media segments, and you don't want to sign each one individually or leak the token in every segment URL visible in browser dev tools. That's where token authentication via cookies or headers comes in.
With token auth, the client gets a single short-lived token after authentication — usually a JWT or a compact HMAC token — and sends it automatically with every request for that scope. The edge validates the token once per request and allows the underlying segments to be fetched without per-URL signing.
Signed Cookies for HLS, DASH, and Private Assets
For video, signed cookies are the cleanest approach I've used in production. The workflow is:
1. User authenticates against your application. Your app creates a policy statement like {"Resource": "https://cdn.example.com/videos/premium/*", "Expires": 1715436800}.
2. You HMAC-sign that policy and set three Set-Cookie headers: CloudFront-Policy, CloudFront-Signature, and CloudFront-Key-Pair-Id (names vary by provider, but the idea is identical).
3. The player requests /videos/premium/master.m3u8 with those cookies, then every segment /videos/premium/720p/seg-0001.ts automatically includes them. The CDN validates the cookie signature and expiry at the edge.
Cookies solve the URL-sharing problem because the token never appears in the URL itself, and they solve the manifest problem because one token covers an entire directory prefix. Keep the cookie Secure, HttpOnly, SameSite=Lax, and scoped to the CDN hostname or a path. Don't set a wildcard domain unless you have to.
// Edge validation example: Cloudflare Worker style token check (simplified)
export async function validateToken(request, env) {
const cookie = request.headers.get('Cookie') || '';
const match = cookie.match(/cdn_token=([^;]+)/);
if (!match) return new Response('Missing token', { status: 403 });
const token = match[1];
// Token format: base64url(payload).base64url(signature)
const [payloadB64, sigB64] = token.split('.');
if (!payloadB64 || !sigB64) return new Response('Malformed token', { status: 403 });
const payloadJson = atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(payloadJson);
// 1. Check expiry and not-before
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return new Response('Token expired', { status: 403 });
if (payload.nbf && payload.nbf > now) return new Response('Token not yet valid', { status: 403 });
// 2. Verify HMAC-SHA256 signature
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', encoder.encode(env.TOKEN_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
);
const data = encoder.encode(payloadB64);
const sigBytes = Uint8Array.from(atob(sigB64.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, data);
if (!valid) return new Response('Invalid signature', { status: 403 });
// 3. Optional: enforce IP binding or path prefix
if (payload.ip && payload.ip !== request.headers.get('cf-connecting-ip')) {
return new Response('IP mismatch', { status: 403 });
}
if (!request.url.startsWith(payload.resource_prefix)) {
return new Response('Resource not covered', { status: 403 });
}
return fetch(request); // token valid, proceed to cache/origin
}
Authorization Headers and Edge Validation
For APIs and software delivery where cookies are awkward, use the Authorization: Bearer <token> header. The edge can validate a JWT using the provider's public key (RS256/ES256) or an HMAC secret. I've found JWTs useful when you already issue them for your application and want the CDN to accept the same token without a separate signing step. Just keep the JWT short-lived and include a narrow aud or resource claim — a general-purpose login JWT that grants access to everything is too broad to use at the edge.
One practical tip: if you validate Authorization headers at the edge, make sure you configure the CDN to not cache responses keyed on that header by default. You want to cache the object once and authorize per request, not create a cache variant per token. Again, custom cache keys or Vary: Authorization handling at the edge logic, not in origin headers, gives you the correct behavior.
Geo-Restriction Logic: Mapping Business Rules to Edge Enforcement
Geo-restriction sounds simple — block country X, allow country Y — until you have to implement it for real content licensing. I've seen contracts that require allowing US and Canada but blocking Quebec, or allowing EU but blocking Germany for specific titles. The CDN's job is to translate those business rules into deterministic edge logic based on the requester's IP location.
Most CDNs derive location from a commercial GeoIP database (MaxMind, Digital Element, etc.) and expose it as variables like client.country, client.region, or X-Geo-Country. Accuracy is good at country level, decent at region/state, and unreliable at city level for mobile networks. Plan accordingly.
Country vs Region vs ASN-Level Controls
Country-level blocking is the blunt instrument and the most reliable. Region-level (state/province) is needed for things like US sports blackouts, but false positives are higher because IP-to-region mapping can be off by one region, especially near borders. ASN-level controls are a workaround for when a carrier's IPs are mis-located — I've had to allowlist a Mexican carrier's ASN that was consistently geolocated to the US despite the users being in Mexico.
Structure your rules as an allowlist rather than a blocklist when compliance is at stake. It's safer to explicitly allow US-CA and deny everything else than to try to enumerate every country to block and accidentally miss one. And always have a clear error response: a 403 with a JSON body explaining "This content is not available in your region" is far better for support than a generic 404 that makes users think the site is broken.
Handling VPNs, Mis-Located IPs, and Compliance Edge Cases
No GeoIP system stops a determined VPN user. If licensing requires stronger assurance, you need to layer signals: anonymous VPN detection lists, hosting/datacenter ASN checks, and sometimes requiring a billing address confirmation at login. Be honest with stakeholders about what geo-restriction actually guarantees — it's a reasonable-effort barrier, not a forensic proof of location.
For mis-located IPs, build an override path. I've implemented a simple edge KV lookup: if IP is in an override list, use the corrected country instead of the GeoIP result. This lets support fix individual user issues without waiting for a GeoIP database update. Also consider IPv6 — its geolocation is often less accurate than IPv4, so test both.
| Method | Best For | Strengths | Limitations to Plan For |
|---|---|---|---|
| Signed URLs | Single files, downloads, time-limited previews | Simple, stateless, no cookie support needed, per-URL expiry | URL can be shared until expiry; leaks in logs/referrers; not ideal for many segments |
| Token Auth (Cookie/Header) | HLS/DASH streaming, private sites, API + CDN mixes | One token covers many objects; hidden from URL; works with players | Requires cookie/header support; needs careful cache-key design; XSS risk if not HttpOnly |
| Geo-Restriction | Licensing, compliance, regional pricing | Zero client integration; enforced automatically at edge | GeoIP inaccuracies; VPN bypass; coarse granularity below country level |
Combining Signals: Layering Signed URLs, Tokens and Geo-Fencing Into One Policy
In production I rarely use one method alone. A typical premium video setup verifies a signed cookie for authentication, an expiry for time bounding, and a geo-check for licensing — all in a single edge function, in a specific order.
The order matters for performance and correctness. I run checks from cheapest and most likely to fail to most expensive:
1. Check geo first. It's a simple string comparison against a variable the CDN already provides. No crypto needed. If the country is not allowed, reject immediately with a clear 451 or 403.
2. Validate token/signature second. This is an HMAC or JWT verification — fast, but more CPU than a string compare.
3. Validate binding (IP, path prefix) last, because it requires parsing the token payload.
For cache interaction, all three signals should influence the decision to serve from cache, but only the resource path should be the cache key. I use edge logic that says: validate all signals → if valid, fetch from cache using normalized path → if invalid, don't even look at cache. This prevents cache poisoning where an attacker might try to populate cache with a valid token then access it without one.
If you need to combine a WAF with access control, keep them as separate phases. Let the WAF run first to drop obvious attacks, then run access control. The separation makes logs clearer and avoids coupling rule updates. Good background on separating concerns at the edge is in Security Headers via CDN: CSP, HSTS and Permissions-Policy at the Edge, which shows how other edge-enforced policies are layered without interfering with caching.
Debugging, Clock Skew, and Key Rotation Without Breaking Playback
The two most common support escalations I see with signed URLs and tokens are "it works for me but not for the user" and "everything broke at 02:00 UTC." Both usually trace back to time and keys.
Clock Skew and Expiry Windows
Clients, edge nodes, and your signing server will never have perfectly synchronized clocks. If you set exp to exactly now + 300 and the user's clock is 60 seconds fast, they get an extra minute. If the edge clock is 30 seconds behind, tokens appear valid longer than intended. I add a 60-second leeway on the validation side but keep expiries short to compensate. For JWTs, the nbf (not before) and iat (issued at) claims with a small clock skew allowance — per IETF guidance on JWT validation — handle this gracefully. Log the exp, now at edge, and delta on every rejection during debugging; it's the fastest way to spot skew.
Also watch for timezone bugs. Always use Unix epoch seconds in UTC. I've debugged a signing service that used local time for exp and produced tokens that were valid for 8 extra hours in production.
Zero-Downtime Key Rotation
Never have a single signing key. Use a key ring with a primary key for signing and 1-2 secondary keys for verification. The flow I use:
1. Generate new key, add it as secondary on all edge nodes. Wait for propagation (usually 30-60 seconds, but I wait 5 minutes).
2. Promote it to primary for new signatures. Old tokens signed with the previous primary still validate because the edge still trusts that key ID.
3. After max_token_lifetime has elapsed (e.g., 24 hours if your longest token is 24 hours), remove the old key.
Include key_id or kid in every token/URL so the edge knows which key to use for verification without trying all of them. Trying all keys works but adds latency and makes debugging harder when you have many rotations. Key distribution itself should be via your CDN's secrets manager or edge KV, not baked into worker code — you want to rotate without a code deploy.
# Example edge config: key rotation verification (pseudo-VCL / Nginx style)
# Keys are fetched from edge KV store, not hardcoded
# Try kid-matched key first, fall back to recent keys during rotation window
sub vcl_recv {
if (req.url ~ "exp=") {
set req.http.X-Key-Id = regsub(req.url, ".*key_id=([^&]+).*", "\1");
set req.http.X-Exp = regsub(req.url, ".*exp=([^&]+).*", "\1");
set req.http.X-Sig = regsub(req.url, ".*sig=([^&]+).*", "\1");
# Reject if expired (with 60s leeway)
if (std.atoi(req.http.X-Exp) < now - 60) {
return (synth(403, "URL expired"));
}
# Fetch correct secret by key_id from KV
set req.http.X-Secret = table.lookup(signing_keys, req.http.X-Key-Id);
if (!req.http.X-Secret) {
# Unknown kid - maybe old key after rotation, try fallback
set req.http.X-Secret = table.lookup(signing_keys, "fallback");
if (!req.http.X-Secret) {
return (synth(403, "Unknown key"));
}
}
# Reconstruct canonical string exactly as signed
set req.http.X-Canonical = req.url.path + ":" + req.http.X-Exp + ":" + req.http.X-Key-Id;
# Verify HMAC-SHA256 (pseudo-function)
if (!hmac_sha256_verify(req.http.X-Canonical, req.http.X-Secret, req.http.X-Sig)) {
return (synth(403, "Invalid signature"));
}
# Valid - normalize cache key to path only for efficiency
set req.url = req.url.path;
}
}
Finally, log verbosely at the edge but carefully. Log token validation failures with a reason code (expired, bad_sig, geo_blocked, ip_mismatch), but never log the full token or secret. Those logs are what let you answer support tickets without asking the user to reproduce the issue. And monitor your 403 rate — a sudden spike often means a key rotation didn't propagate or a GeoIP database update reclassified a large carrier.
Frequently Asked Questions
Should I use signed URLs or signed cookies for video streaming?
For single files like downloads or images, signed URLs are simpler. For HLS/DASH where a manifest references dozens of segments, signed cookies are almost always better. One cookie covers the entire path prefix, so the player doesn't need to rewrite segment URLs and the token doesn't appear in every request URL. I've migrated several video platforms from per-segment signed URLs to signed cookies and seen both lower origin load and fewer leaked URLs in logs as a result.
How long should signed URLs and tokens live?
As short as your user experience allows. For downloads, 5-15 minutes is typical — long enough to handle slow networks but short enough that a shared link quickly dies. For streaming, I issue cookies that live 2-4 hours and refresh them via an API call when the player is still active. For live events, 60-120 seconds per token with frequent refresh gives tighter control. Longer expiries increase the window for link sharing and replay attacks, so avoid 24-hour links unless you have a specific offline use case.
Can geo-restriction be bypassed with a VPN?
Yes, and you should assume it will be. Country-level GeoIP checks stop casual users but not someone intentionally using a VPN or residential proxy. If licensing requires stronger enforcement, layer anonymous VPN/proxy detection, block known hosting ASNs, and tie access to an authenticated account with a verified billing address. Be transparent with stakeholders that geo-restriction is a reasonable-effort control, not an absolute guarantee, and build your threat model accordingly.
How do I debug a 403 from token validation without exposing secrets?
Add detailed reason codes at the edge without echoing the token. Return or log values like expired: exp=1715436800 now=1715436900 delta=100, invalid_signature kid=k2024-11, geo_blocked country=DE allowed=US,CA, or ip_mismatch token_ip=203.0.113.45 client_ip=198.51.100.10. On the client, use browser dev tools to verify cookies/headers are actually being sent and not blocked by SameSite or third-party cookie settings. Clock skew between your signing server and edge is a frequent culprit, so always log both timestamps in UTC epoch seconds.