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
Generate a key and set PREVIEWPROXY_SOURCE_URL_ENCRYPTION_KEY:
openssl rand -hex 32
PREVIEWPROXY_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.
Use PREVIEWPROXY_SOURCE_URL_ENCRYPTION_KEY together with PREVIEWPROXY_HMAC_KEY signing. Encryption hides the source URL, but signing prevents padding oracle attacks by ensuring only trusted clients can submit requests.
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 ifPREVIEWPROXY_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
Path-style
GET /{transforms}/enc/<encrypted-blob>
# No transforms
GET /enc/<blob>
# With transforms
GET /300x200,webp/enc/<blob>
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=200
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.
Because the IV is derived deterministically, an observer who sees two identical blobs knows the underlying URLs are the same. If URL equality must also be hidden, rotate your key periodically.
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
PREVIEWPROXY_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
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
import hmac, hashlib, base64
from Crypto.Cipher import AES
from 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}')