Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ParameterInclude?Why
HTTP methodRecommendedPrevent GET token being used for DELETE
URL pathRequiredPrevent token for /video/1 being used for /video/2
Expiry timestampRequiredTime-bound the token
Client IPOptionalIP-locked tokens prevent sharing; breaks VPNs
Content typeOptionalPrevent download link being used for upload
Key versionVia URLEnables 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:

  1. Generate new key; add as v2 to CDN config (old key still active)
  2. Configure application server to sign new URLs with v2
  3. Wait for all outstanding v1 tokens to expire (or force-expire them)
  4. Remove v1 from 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

VendorSigned URL mechanism
CloudflareSigned URLs + Token Auth (Workers or built-in)
FastlySigned tokens via VCL
AWS CloudFrontSigned URLs (RSA) or Signed Cookies
AkamaiEdge 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