Source URL Encryption
Source URL encryption lets you hide upstream origins from request logs, CDN traces, and browser history. When enabled, callers pass an AES-CBC encrypted blob instead of the raw URL; PreviewProxy decrypts it server-side before fetching.
Enabling source URL encryption
Section titled “Enabling source URL encryption”Generate a key and set PP_SOURCE_URL_ENCRYPTION_KEY:
openssl rand -hex 32PP_SOURCE_URL_ENCRYPTION_KEY=1eb5b0e971ad7f45324c1bb15c947cb207c43152fa5c6c7f35c4f36e0c18e0f1| Key length | Hex chars | Algorithm |
|---|---|---|
| 16 bytes | 32 | AES-128-CBC |
| 24 bytes | 48 | AES-192-CBC |
| 32 bytes | 64 | AES-256-CBC |
PreviewProxy validates the key length at startup and will refuse to start if the length is invalid.
Encrypting a URL
Section titled “Encrypting a URL”Use the built-in CLI helper to produce an encrypted blob:
previewproxy encrypt-url "https://cdn.example.com/photo.jpg" [--key <hex-key>]--keyis optional ifPP_SOURCE_URL_ENCRYPTION_KEYis set in the environment or.envfile- stdout - the encrypted blob (pipe-friendly)
- stderr - the ready-to-use path segment
/enc/<blob>
Making requests
Section titled “Making requests”Path-style
Section titled “Path-style”GET /{transforms}/enc/<encrypted-blob># No transformsGET /enc/<blob>
# With transformsGET /300x200,webp/enc/<blob>Query-style
Section titled “Query-style”Add enc to the query (any value, or just the key) to signal that url is encrypted:
GET /proxy?url=<encrypted-blob>&enc=1&w=300&h=200Algorithm
Section titled “Algorithm”- IV derivation -
HMAC-SHA256(key, plaintext_url)truncated to 16 bytes. The IV is deterministic so the same URL always produces the same encrypted blob, preserving CDN cache hits. - Encryption - PKCS#7 pad the URL, then AES-CBC encrypt with the derived IV.
- Encoding - Concatenate
IV || ciphertextand encode as URL-safe base64 (no padding).
Decryption reverses these steps: base64 decode, split IV, AES-CBC decrypt, strip PKCS#7 padding.
IV generation
Section titled “IV generation”AES-CBC requires the IV to be unique between different encrypted messages (source URLs in our case). The choice of IV strategy involves a tradeoff between security and CDN cacheability:
The core tension: using a unique IV every time you encrypt the same source URL produces a different ciphertext each time, and thus a different encrypted blob. This means requests to the same origin URL will never hit the CDN cache, defeating a key benefit of a proxy.
On the other hand, reusing the same IV with the same message is safe as long as the IV is never reused with a different message under the same key. Two practical approaches handle this:
Option 1 - Cache IVs: Store the IV alongside each source URL so it is generated only once per URL and reused on subsequent encryptions. Depending on your security requirements, consider encrypting the stored IVs with a separate key so that a database leak does not reveal message-IV pairs.
Option 2 - Deterministic derivation (PreviewProxy’s approach): Compute HMAC-SHA256(key, plaintext_url) and truncate to 16 bytes. This is the method PreviewProxy uses. While it does not mathematically guarantee IV uniqueness across all possible inputs, the probability of collision is negligible in practice, and it requires no storage.
Step-by-step encryption example
Section titled “Step-by-step encryption example”The following example walks through the AES-256-CBC encryption process that PreviewProxy implements.
Key (hex-encoded, 32 bytes):
1eb5b0e971ad7f45324c1bb15c947cb207c43152fa5c6c7f35c4f36e0c18e0f1Source URL (39 bytes):
http://example.com/images/curiosity.jpgStep 1 - PKCS#7 pad to a 16-byte boundary.
The URL is 39 bytes; the next multiple of 16 is 48, so 9 bytes of \x09 are appended:
http://example.com/images/curiosity.jpg\x09\x09\x09\x09\x09\x09\x09\x09\x09Step 2 - Derive the IV.
Using HMAC-SHA256(key, url) truncated to 16 bytes (shown as raw bytes):
\xA7\x95\x63\xA2\xB3\x5D\x86\xCE\xE6\x45\x1C\x3C\x80\x0F\x53\x5AStep 3 - AES-256-CBC encrypt the padded URL.
Ciphertext (48 bytes):
\x84\x65\x19\xC8\xB7\x97\x59\x2E\xCE\xA3\x78\xDD\x44\x25\x45\xA4\x48\x43\x4A\xAD\x04\xA5\xB7\xA8\x50\x01\x22\xCC\x7E\x65\x1C\xFF\x71\x57\x3C\x89\x54\xD8\x6E\x1B\x0D\xB3\x13\x41\x2F\x50\x47\x69Step 4 - Prepend the IV to the ciphertext (64 bytes total):
\xA7\x95\x63\xA2\xB3\x5D\x86\xCE\xE6\x45\x1C\x3C\x80\x0F\x53\x5A\x84\x65\x19\xC8\xB7\x97\x59\x2E\xCE\xA3\x78\xDD\x44\x25\x45\xA4\x48\x43\x4A\xAD\x04\xA5\xB7\xA8\x50\x01\x22\xCC\x7E\x65\x1C\xFF\x71\x57\x3C\x89\x54\xD8\x6E\x1B\x0D\xB3\x13\x41\x2F\x50\x47\x69Step 5 - URL-safe base64 encode (no padding):
p5VjorNdhs7mRRw8gA9TWoRlGci3l1kuzqN43UQlRaRIQ0qtBKW3qFABIsx-ZRz_cVc8iVTYbhsNsxNBL1BHaQThe final PreviewProxy URL path:
/enc/p5VjorNdhs7mRRw8gA9TWoRlGci3l1kuzqN43UQlRaRIQ0qtBKW3qFABIsx-ZRz_cVc8iVTYbhsNsxNBL1BHaQKey rotation
Section titled “Key rotation”There is no built-in key rotation. To rotate:
- Generate a new key with
openssl rand -hex 32 - Re-encrypt all stored URLs with the new key
- Update
PP_SOURCE_URL_ENCRYPTION_KEYand redeploy
Requests using blobs encrypted with the old key will return 400 Bad Request after the key is changed.
Code examples
Section titled “Code examples”Node.js
Section titled “Node.js”const crypto = require("crypto")
function encryptUrl(url, keyHex) { const key = Buffer.from(keyHex, "hex") // Deterministic IV: HMAC-SHA256(key, url) truncated to 16 bytes const iv = crypto.createHmac("sha256", key).update(url).digest().slice(0, 16) const cipher = crypto.createCipheriv("aes-256-cbc", key, iv) const encrypted = Buffer.concat([cipher.update(url, "utf8"), cipher.final()]) return Buffer.concat([iv, encrypted]) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, "")}
const blob = encryptUrl( "https://cdn.example.com/photo.jpg", "1eb5b0e971ad7f45324c1bb15c947cb207c43152fa5c6c7f35c4f36e0c18e0f1",)console.log(`/enc/${blob}`)Python
Section titled “Python”import hmac, hashlib, base64from Crypto.Cipher import AESfrom Crypto.Util.Padding import pad
def encrypt_url(url: str, key_hex: str) -> str: key = bytes.fromhex(key_hex) url_bytes = url.encode() # Deterministic IV: HMAC-SHA256(key, url) truncated to 16 bytes iv = hmac.new(key, url_bytes, hashlib.sha256).digest()[:16] cipher = AES.new(key, AES.MODE_CBC, iv) ct = cipher.encrypt(pad(url_bytes, AES.block_size)) blob = base64.urlsafe_b64encode(iv + ct).rstrip(b'=').decode() return blob
blob = encrypt_url( 'https://cdn.example.com/photo.jpg', '1eb5b0e971ad7f45324c1bb15c947cb207c43152fa5c6c7f35c4f36e0c18e0f1')print(f'/enc/{blob}')