Build an Image CDN Proxy with On-the-Fly Resizing
A complete image CDN proxy: signed resize URLs, cache headers, conditional requests, format negotiation, and cache-key tests.
Project goal
Proxy an origin image, resize it on demand from a signed URL, negotiate modern formats, and cache aggressively without serving one user variant to another.
Final features
- Signed parameters
- Cache key
- Format negotiation
- Immutable caching
- Conditional requests
Architecture
A signed URL encodes the width, format, and quality with an HMAC so only trusted callers generate variants. The proxy checks a cache keyed by origin plus parameters, otherwise fetches, resizes, and stores the result with immutable cache headers.
Prerequisites
- Node.js 20 LTS or later
- An origin image host
- libvips for sharp
Dependencies
- sharp ^0.33.4 — Resizing and format conversion
- ioredis ^5.4.1 — Variant metadata cache
- express ^4.21.0 — Proxy endpoint
Directory structure
image-cdn-proxy/
├── src/sign.ts
├── src/resize.ts
├── src/cache.ts
├── src/negotiate.ts
├── tests/cdn.test.ts
└── package.json
Phases
1. Signed parameters
HMAC the width, format, and source so callers cannot invent variants.
if (hmac(params) !== sig) return res.status(403).end();
Expected behavior: An open resize API is a CPU denial-of-service vector.
2. Cache key
Build a key from the source hash plus every rendering parameter.
const key = [sha256(src), w, h, fmt, q].join(':');
Expected behavior: A missing parameter in the key serves the wrong image.
3. Format negotiation
Serve AVIF or WebP when the Accept header allows it.
const fmt = req.accepts(['avif', 'webp', 'jpeg']);
Expected behavior: Modern formats cut bytes substantially at equal quality.
4. Immutable caching
Set long cache headers because the URL fully determines the bytes.
res.set('Cache-Control', 'public, max-age=31536000, immutable');
Expected behavior: Variant URLs are content-addressed by their parameters.
5. Conditional requests
Honor If-None-Match with the variant ETag.
if (req.headers['if-none-match'] === etag) return res.status(304).end();
Expected behavior: Clients avoid re-downloading unchanged variants.
Testing
Request a signed variant and assert dimensions and content type; reuse the URL and assert a cache hit; tamper with the signature and assert 403.
Troubleshooting
Users see another user image
The cache key omitted a parameter that changes the bytes; include every rendering input.
CPU saturates
Unsigned or uncapped requests let clients request huge variants; sign and cap dimensions.
Deployment
Put a shared cache in front of the proxy and keep variant generation on CPU-constrained workers. Cap the maximum dimensions to bound cost per request.
Known limitations
- No image optimization beyond resize and format.
- Cache is per instance plus Redis metadata.
- No video or animated image support.
Project overview
An image CDN that resizes on demand is easy to write and easy to turn into an open image-processing service for anyone who can guess a URL. This build authenticates the parameters with a signature, so only callers you trust can ask for a variant, and the cost of each variant is bounded in advance.
You will encode width, format, and quality into a signed URL, derive a cache key from the origin and the parameters, fetch and resize on a miss, and store the result with immutable headers so the expensive path runs once per variant.
The signature is the whole security model: without it, the proxy is a free compute endpoint that will happily decode a large image thousands of times for an attacker who varies a parameter. With it, the caller must already hold a secret that your application issues, which turns an open endpoint into a private one.
The cache key is the correctness model. Two different variants must never share an entry, or one user receives an image sized for another, and a cache that serves the wrong bytes is worse than a cache that misses.
Each phase adds one property: signed parameters, a correct key, format negotiation, immutable caching, and conditional requests, so the proxy is useful and safe after any of them.
The proxy is aimed at teams that host images themselves and want the benefits of a CDN without shipping a full processing pipeline. It keeps the transformation code small and puts the security and caching decisions up front, where they can be reviewed rather than discovered in a bill.
What you will build
The proxy provides five capabilities, each with a test that inspects the response and the cache:
- Signed parameters
- Cache key
- Format negotiation
- Immutable caching
- Conditional requests
Signed parameters bound what a caller can ask for; a cache key isolates variants; format negotiation serves modern encodings to clients that accept them; immutable caching makes the expensive path run once; and conditional requests let a warm client skip the body entirely.
The design keeps variant generation on CPU-constrained workers and puts a shared cache in front, so the cost of a resize is paid once and the network does the rest.
Architecture and data model
A signed URL encodes the width, format, and quality with an HMAC so only trusted callers generate variants. The proxy checks a cache keyed by origin plus parameters, otherwise fetches, resizes, and stores the result with immutable cache headers.
A signed URL carries the origin, the width, the format, and the quality, with an HMAC over those parameters. The proxy verifies the signature before doing any work, so an unsigned or tampered request is rejected without touching the origin or the image pipeline.
The cache key is the origin plus every parameter that changes the output, so two variants with different widths never collide. Including the format and quality in the key matters, because a cache that ignores them will serve a webp to a client that asked for png.
Format negotiation reads the accept header and chooses the best supported encoding, falling back to the origin format when none is acceptable. The chosen format becomes part of the key, so the same logical variant in two encodings is cached separately.
Responses carry immutable cache headers keyed by the signature, which lets a shared cache serve the same bytes to every caller of that signed URL. A short expiry on the signature bounds how long a leaked URL is useful.
Because the signature covers every parameter that affects the output, adding a transform option is a signing change as well as a code change; that coupling is deliberate, since it prevents a client from inventing parameters the server never intended to support.
Prerequisites and dependencies
You need an image library, a shared cache in front of the service, and the same secret on every instance that signs URLs.
- Node.js 20 LTS or later
- An origin image host
- libvips for sharp
The dependencies cover the HTTP layer, the image transformation, and the object or memory cache:
sharp(^0.33.4) — Resizing and format conversionioredis(^5.4.1) — Variant metadata cacheexpress(^4.21.0) — Proxy endpoint
pnpm init
pnpm add sharp ioredis express
pnpm add -D typescript tsx vitest
Keep the signing secret in a secret manager and rotate it with an overlap window, so URLs signed by the old key keep working until they expire. Cap the maximum width, height, and output size in configuration so no single request can request an unbounded transform.
Directory structure
Separate signature verification, key derivation, format negotiation, transformation, and caching so each can be tested with a fixed input image:
image-cdn-proxy/
├── src/sign.ts
├── src/resize.ts
├── src/cache.ts
├── src/negotiate.ts
├── tests/cdn.test.ts
└── package.json
Phase 1: Signed parameters
HMAC the width, format, and source so callers cannot invent variants.
if (hmac(params) !== sig) return res.status(403).end();
An open resize API is a CPU denial-of-service vector.
Sign the variant parameters with an HMAC and verify before doing any work. Verifying first is what prevents an attacker from using the resizer as a free CPU pool by varying parameters on an unsigned URL.
Phase 2: Cache key
Build a key from the source hash plus every rendering parameter.
const key = [sha256(src), w, h, fmt, q].join(':');
A missing parameter in the key serves the wrong image.
Derive the cache key from the origin and every parameter that changes the output, including format and quality. A key that omits one of them will serve a wrong variant confidently, which is harder to notice than a miss.
Phase 3: Format negotiation
Serve AVIF or WebP when the Accept header allows it.
const fmt = req.accepts(['avif', 'webp', 'jpeg']);
Modern formats cut bytes substantially at equal quality.
Read the accept header and choose an encoding the client supports, falling back to the origin when none is acceptable. Record the chosen format in the key so two encodings are cached separately rather than overwriting each other.
Phase 4: Immutable caching
Set long cache headers because the URL fully determines the bytes.
res.set('Cache-Control', 'public, max-age=31536000, immutable');
Variant URLs are content-addressed by their parameters.
Store the result with immutable cache headers keyed by the signature, so a shared cache can serve it repeatedly and the resize path runs once per variant. Bound the signature lifetime so a shared URL stops working after a reasonable window.
Phase 5: Conditional requests
Honor If-None-Match with the variant ETag.
if (req.headers['if-none-match'] === etag) return res.status(304).end();
Clients avoid re-downloading unchanged variants.
Support conditional requests so a warm client no longer downloads the bytes, and honour range requests for large images. These are cheap to add and remove most of the bandwidth for repeat views.
Testing
Request a signed variant and assert dimensions and content type; reuse the URL and assert a cache hit; tamper with the signature and assert 403.
Request a signed variant and assert both the dimensions and the content type, because a correct size with the wrong encoding is still a broken image for the client.
Reuse the same signed URL and assert a cache hit rather than a second transform, using an origin stub that counts fetches so the assertion is about behavior rather than latency.
Tamper with one signed parameter and assert 403, since this is the only thing standing between a private endpoint and a free image-processing service.
Assert that two variants with different widths produce distinct cache entries, which catches the key bug that serves one user’s image to another.
Test with a tiny generated image so the assertions are about dimensions and content type rather than about pixel content, which keeps the suite fast and the failures readable.
pnpm vitest run
Deployment and operations
Put a shared cache in front of the proxy and keep variant generation on CPU-constrained workers. Cap the maximum dimensions to bound cost per request.
Put a shared cache in front of the proxy and keep transformation on CPU-constrained workers, so a burst of variants does not starve the request path that serves them.
Cap the maximum dimensions and output size, because an unbounded transform is a trivial way to consume all the memory and CPU on a worker with one request.
Monitor the transform rate and the cache hit ratio together, since a falling hit ratio means the key or the headers changed, and a rising transform rate means the cache is not holding.
Rotate the signing secret with an overlap window, and keep the old key valid for at least the URL lifetime so a rotation does not break links already in flight.
Set the cache TTL from the signature lifetime rather than independently, because a cache that outlives a signature keeps serving a URL the signer intended to expire.
Security considerations
Rate-limit unsigned requests, cap dimensions, and never proxy arbitrary remote hosts. Verify the signature before reading the origin to avoid unnecessary work.
Verify the signature before any work, and never trust a parameter you did not sign; an unsigned resize endpoint is a denial-of-service target.
Treat the origin URL as an allowlisted host, so a signed request cannot be used to make the proxy fetch an internal address the signer did not intend.
Bound the output size and the decode memory, since a decompression bomb in an image is a way to exhaust a worker with a small request.
Keep the signing secret server-side and never expose it to the client; the client receives signed URLs, not the ability to sign them.
Block requests to private address ranges at the origin fetch step, so even a signed request cannot be used to probe internal services; the signature protects the endpoint, not the network.
Observability
Track cache hit ratio, resize latency, and bandwidth saved. Alert on a falling hit ratio, which usually means cache keys changed shape.
Track the transform rate, the cache hit ratio, and the average transform duration, because together they describe whether the service is cheap or accidentally expensive.
Chart the share of requests rejected for a bad signature, since a spike indicates either an attack or a signing bug on the caller side.
Measure the origin fetch rate separately from the cache hit rate, because a falling hit rate with a stable origin fetch rate means the cache layer, not the origin, is the problem.
Alert on transform latency, because a short queue of large images can make the p99 climb without any change in request volume.
Track the distribution of requested dimensions, because a small set of sizes indicates a healthy client while a long tail of near-identical sizes wastes transforms and is easy to fix in the client.
Troubleshooting
Users see another user image. The cache key omitted a parameter that changes the bytes; include every rendering input.
CPU saturates. Unsigned or uncapped requests let clients request huge variants; sign and cap dimensions.
If a variant is served at the wrong size, check the cache key first, because a key that omits a parameter will return another variant’s bytes and every other metric will look normal.
If every request transforms again, compare the response headers with the cache configuration, since a header that varies per request prevents caching even when the key is correct.
If the cache never hits, compare the vary headers with the cache key rather than blaming the origin, because the two must agree for a shared cache to store anything.
Limitations and next steps
- No image optimization beyond resize and format.
- Cache is per instance plus Redis metadata.
- No video or animated image support.
The proxy generates variants on demand and does not pre-generate them, so the first request for a variant pays the transform cost and later requests do not.
There is no automatic purge when an origin image changes, so a variant cached under an unchanged signature stays until its expiry; versioning the origin URL is the recommended workaround.
Format negotiation covers the common modern encodings but not advanced ones such as next-generation formats that require a separate encoder, which the build leaves out.
There is no per-caller quota in this service; abuse control is delegated to the signature, so a caller that legitimately holds the signing key can still generate unlimited variants.
The service does not store originals; it fetches from the origin on every cache miss, so a cold variant costs an origin round trip as well as a transform.