Lab 16 · Signed URLs & Token Authentication
Run it:
make lab-16
Source:labs/lab-16-signed-urls/main.go
The Problem
Public CDN caching works for content anyone can access. But what about:
- A Netflix video: only the paying subscriber should be able to access it
- A signed download link: expires in 1 hour
- A presigned S3 URL: locked to a specific IP address
- A livestream: viewers who joined must stay authorized, not share URLs
The CDN must enforce authorization at the edge, before delivering content, without calling the origin for every request (that would destroy the CDN’s performance advantage).
Signed URLs solve this: the application server generates a URL that contains a cryptographic signature. The CDN verifies the signature without contacting the origin.
HMAC-SHA256: The Signature Primitive
HMAC (Hash-based Message Authentication Code) uses a secret key and a hash function (SHA-256 here) to produce an authentication code:
HMAC-SHA256(key, message) = H(key XOR opad || H(key XOR ipad || message))
Properties:
- Unforgeability: without the key, it’s computationally infeasible to produce a valid MAC for a different message
- Key-binding: same message + different key → different MAC
- Non-collision: different messages → different MACs (with overwhelmingly high probability)
This is the same primitive used by JWT (HS256 variant), AWS Signature V4, and cookie signing in Django/Rails.
import "crypto/hmac"
import "crypto/sha256"
func sign(key []byte, message string) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(message))
return hex.EncodeToString(mac.Sum(nil))
}
// Verify — always use hmac.Equal, never ==
func verify(key []byte, message, signature string) bool {
expected := sign(key, message)
// hmac.Equal is constant-time to prevent timing attacks
return hmac.Equal([]byte(signature), []byte(expected))
}
The Canonical String
The signature must cover all inputs that should be tamper-proof. The lab uses:
func canonicalString(method, path string, expires int64, clientIP string) string {
// Deterministic: same inputs always produce same string
return fmt.Sprintf("%s\n%s\n%d\n%s",
strings.ToUpper(method), // GET
path, // /video/movie.mp4
expires, // Unix timestamp
clientIP, // 1.2.3.4 (or "" if not IP-bound)
)
}
This canonical string is signed. The URL then carries:
/video/movie.mp4?expires=1735689600&ip=1.2.3.4&sig=a1b2c3d4...&keyver=v2
What to include in the canonical string
| Parameter | Include? | Why |
|---|---|---|
| HTTP method | Recommended | Prevent GET token being used for DELETE |
| URL path | Required | Prevent token for /video/1 being used for /video/2 |
| Expiry timestamp | Required | Time-bound the token |
| Client IP | Optional | IP-locked tokens prevent sharing; breaks VPNs |
| Content type | Optional | Prevent download link being used for upload |
| Key version | Via URL | Enables rotation without invalidating all tokens |
Timing-Safe Comparison: hmac.Equal
Never use == or bytes.Equal to compare HMACs. These perform
byte-by-byte comparison and short-circuit on the first mismatch.
An attacker can measure response time to determine how many bytes of their forged signature match the real signature (timing oracle). With enough requests:
sig[0] == correct? → 200 ns (one comparison)
sig[0] != correct? → 100 ns (short-circuit)
→ Binary search on each byte → forge a valid signature in O(256×32) = 8192 requests
hmac.Equal (and subtle.ConstantTimeCompare) always compare the full
input regardless of where the first mismatch is, eliminating the timing
oracle:
// WRONG — timing oracle vulnerability
if signature != expected {
return false
}
// CORRECT — constant-time comparison
if !hmac.Equal([]byte(signature), []byte(expected)) {
return false
}
This is OWASP Top 10 territory (A07: Identification and Authentication Failures).
Key Rotation
Secrets must be rotatable without invalidating all outstanding tokens.
The URL carries a keyver parameter:
/video/movie.mp4?sig=abc123&keyver=v1&expires=...
/video/movie.mp4?sig=xyz789&keyver=v2&expires=...
The CDN maintains multiple keys:
var signingKeys = map[string][]byte{
"v1": []byte("old-secret-key"), // still accepted for in-flight URLs
"v2": []byte("new-secret-key-2025"), // current signing key
}
func verifySignedURL(r *http.Request) bool {
keyver := r.URL.Query().Get("keyver")
key, ok := signingKeys[keyver]
if !ok { return false }
// Verify with the key for this version
return hmac.Equal(
[]byte(computeExpectedSig(key, r)),
[]byte(r.URL.Query().Get("sig")),
)
}
Rotation procedure:
- Generate new key; add as
v2to CDN config (old key still active) - Configure application server to sign new URLs with
v2 - Wait for all outstanding
v1tokens to expire (or force-expire them) - Remove
v1from CDN config
Expiry Validation
func checkExpiry(r *http.Request) bool {
expiresStr := r.URL.Query().Get("expires")
expires, err := strconv.ParseInt(expiresStr, 10, 64)
if err != nil { return false }
return time.Now().Unix() < expires
}
Clock skew: CDN nodes across PoPs may have slight clock differences. Add a small grace period (30–60 seconds) to tolerate this:
return time.Now().Unix() < expires + 60 // 60-second grace window
IP Binding
Binding a signed URL to the client’s IP prevents the link from being shared. When a user logs into your streaming service and requests a video URL:
Application: token = sign(path, expires, clientIP="1.2.3.4")
CDN: verify(path, expires, clientIP=request.RemoteAddr)
If the user shares the URL with a friend (IP 5.6.7.8), the CDN
rejects with 403 Forbidden.
Tradeoff: IP binding breaks users on:
- VPNs (IP changes between token generation and use)
- Mobile networks (IP changes during handoff)
- Large corporate NAT (all employees share one IP — one user’s token would be usable by all)
Most streaming services IP-bind only for high-value content or use short-TTL tokens (15 minutes) instead.
Vendor Implementation
| Vendor | Signed URL mechanism |
|---|---|
| Cloudflare | Signed URLs + Token Auth (Workers or built-in) |
| Fastly | Signed tokens via VCL |
| AWS CloudFront | Signed URLs (RSA) or Signed Cookies |
| Akamai | Edge Auth Token |
AWS CloudFront uses RSA signatures (asymmetric): the application signs with a private key; CloudFront verifies with the public key. This means the CDN never needs to know the private key — useful when you don’t fully trust the CDN operator with the signing secret.
Try It
make lab-16
# Generate a signed URL (the lab provides a /sign endpoint for testing)
SIGNED=$(curl -s "http://localhost:8080/sign?path=/video/movie.mp4&ttl=300")
echo "Signed URL: $SIGNED"
# Access with valid signature
curl "$SIGNED" -v
# Access without signature — should be 403
curl "http://localhost:8080/video/movie.mp4" -v
# Expired token (manipulate the expires param)
EXPIRED=$(echo "$SIGNED" | sed 's/expires=[0-9]*/expires=1000000000/')
curl "$EXPIRED" -v # should be 403
# Wrong IP (change the ip param if IP-bound)
curl "$SIGNED" -v # will succeed from your IP
# → Serving signed content from a different IP would fail