Media CDN skill for Claude Code
Signed uploads, signed URLs, no surprise media bills. Claude sets up image and video delivery so it is fast, private where it must be, and hard to abuse: server-signed direct uploads to Cloudinary or Mux, a URL builder limited to an allowlisted width ladder with signed or strict transformations, short-lived Mux playback tokens minted after an ownership check, and asset state updated only from signature-verified webhooks.
- name
media-cdn - size201 lines · ≈2,750 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Delivers user and product media through Cloudinary, imgix, and Mux - signed direct uploads and upload widgets, URL transformations with f_auto and q_auto or auto=format, strict transformations and URL signing to stop cost abuse, Mux direct uploads, signed playback JWTs, HLS and mux-player, verified Mux and Cloudinary webhooks, and private assets. Use when adding image or video uploads, serving resized images from a media CDN, streaming video, protecting paid or private media, or investigating a surprise transformation or bandwidth bill. Try asking
- Let users upload profile photos straight to Cloudinary without exposing our API secret
- Add video uploads and paid-only playback with Mux signed URLs
- Our imgix bill tripled last month. Lock down which image variants can be generated
Use it when
- Adding user image or video uploads backed by a media service
- Serving resized, format-negotiated images from Cloudinary or imgix
- Streaming video with adaptive bitrate and protected playback
- Transformation or bandwidth costs spike unexpectedly
Skip it when
- You need srcset, sizes, and LCP tuning for page markup; use image-optimization instead
- You only need raw file storage with presigned links; use s3-object-storage instead
- The main concern is validating hostile upload contents; use secure-file-uploads instead
How to check the result
A tampered transformation URL is refused, signed Mux streams fail without or with expired tokens, tampered or stale webhooks are rejected, and deleting a test user removes their assets at the provider.
The common mistake it prevents
Leaving transformations open so anyone can request arbitrary widths and formats, each one billed as a new derived asset.
Done well means: files go from the browser straight to the provider with server-signed parameters, every delivery URL is one of a known set of transformations (signed or allowlisted, so nobody can mint a million variants on your bill), private media needs a short-lived token, asset state in your database is driven by verified webhooks, and deleting a user deletes their media.
Ground rules
CLOUDINARY_API_SECRET,IMGIX_SECURE_TOKEN,MUX_TOKEN_SECRET, Mux signing-key private keys, and webhook secrets live in a secret manager. Only public identifiers (cloud name, API key, imgix domain, playback IDs) reach the client.- Never proxy large uploads through your app server. Sign on the server, upload from the client.
- Treat uploaded media as untrusted: size and type limits at the provider (upload preset or Mux settings) plus your own checks. See secure-file-uploads for validation depth.
- Store the provider’s IDs (
public_id, Muxasset_id,playback_id) and your own ownership row. Never derive ownership from a URL the client sends back.
Choose the service
| Need | Default | Notes |
|---|---|---|
| Images (and some video) uploaded by users, transformed on the fly | Cloudinary | Upload API, widget, transforms, moderation add-ons |
| Images already in your S3/GCS bucket, just need resizing and formats | imgix (or Cloudflare Images) | Points at your origin; no upload pipeline to adopt |
| Video playback with adaptive streaming, players, analytics | Mux | Encodes to HLS, signed playback, direct uploads |
| Static marketing images in the repo | Framework image component, no media CDN | See image-optimization |
| Raw file storage and presigned S3 downloads | S3 or R2 directly | See s3-object-storage |
Workflow
- [ ] 1. Pick the service per media type; define the transformation allowlist
- [ ] 2. Upload: server signs, client uploads directly; record a pending asset row
- [ ] 3. Webhook: verify signature, mark asset ready, store IDs and dimensions
- [ ] 4. Delivery: URL builder that only emits allowlisted, signed variants
- [ ] 5. Private media: authenticated types or signed playback tokens with short expiry
- [ ] 6. Lifecycle: delete and invalidate on user deletion; monitor usage
- [ ] 7. Verify with tampered URLs, tampered webhooks, and a cost check
2. Uploads
Cloudinary signed upload. The server signs every parameter the client will send (except
file, cloud_name, resource_type, api_key); the signature is a SHA-1 hex digest (SHA-256
if your account is configured for it) of the sorted key=value&... string followed by the API
secret. Use the SDK helper rather than hand-rolling it:
import { v2 as cloudinary } from "cloudinary";
cloudinary.config({ cloud_name: process.env.CLOUDINARY_CLOUD_NAME, api_key: process.env.CLOUDINARY_API_KEY, api_secret: process.env.CLOUDINARY_API_SECRET });
export function signUpload(userId: string) {
const params = {
timestamp: Math.round(Date.now() / 1000), // signatures expire about an hour after this
folder: `users/${userId}`,
upload_preset: "user_avatars_signed", // preset sets max size, allowed formats, moderation
context: `owner=${userId}`,
};
const signature = cloudinary.utils.api_sign_request(params, process.env.CLOUDINARY_API_SECRET!);
return { ...params, signature, api_key: process.env.CLOUDINARY_API_KEY, cloud_name: process.env.CLOUDINARY_CLOUD_NAME };
}
The client POSTs the file plus exactly these fields to
https://api.cloudinary.com/v1_1/<cloud>/image/upload. With the Upload Widget, supply the
same server call through its uploadSignature callback. Unsigned presets let anyone with your
cloud name upload; use them only with tight preset limits and moderation.
Mux direct upload: create the upload on the server, give the client only the one-time URL.
import Mux from "@mux/mux-node";
const mux = new Mux(); // reads MUX_TOKEN_ID / MUX_TOKEN_SECRET from env
export async function createVideoUpload(videoRowId: string) {
const upload = await mux.video.uploads.create({
cors_origin: "https://app.example.com",
new_asset_settings: {
playback_policies: ["signed"], // default for anything not public
passthrough: videoRowId, // your ID, echoed back in webhooks
max_resolution_tier: "1080p", // cap encode and delivery cost
},
});
return { uploadId: upload.id, url: upload.url }; // client PUTs the file (use UpChunk for resumable chunks)
}
Field names in the Mux SDK have shifted between major versions (playback_policy vs
playback_policies, encoding_tier vs video_quality); check the installed version’s types.
3. Webhooks drive state
| Provider | Verify | Events to handle |
|---|---|---|
| Mux | Mux-Signature: t=<ts>,v1=<hex>; HMAC-SHA256 over "<ts>." + rawBody with the endpoint secret; 5-minute tolerance | video.upload.asset_created, video.asset.ready, video.asset.errored, video.asset.deleted |
| Cloudinary | X-Cld-Signature + X-Cld-Timestamp; hex SHA-1 (or SHA-256) of rawBody + timestamp + api_secret; reject stale timestamps | upload, moderation results, eager transform completion |
// Mux: SDK helper throws on bad signature or stale timestamp
mux.webhooks.verifySignature(rawBody, req.headers, process.env.MUX_WEBHOOK_SECRET);
// Cloudinary: SDK helper; validFor is seconds of allowed age
const ok = cloudinary.utils.verifyNotificationSignature(rawBody, Number(req.headers["x-cld-timestamp"]), String(req.headers["x-cld-signature"]), 600);
Both need the raw body string exactly as received. Cloudinary also offers newer signature
schemes per notification trigger (including Ed25519); if you enable one, follow its doc.
Dedupe on the event ID (Mux id) or (public_id, version) for Cloudinary, and process
asynchronously. Mark an asset usable only after video.asset.ready or the upload notification.
4. Delivery URLs
Image defaults that are almost always right: automatic format (AVIF/WebP), automatic quality, width-limited, never upscaled.
| Service | URL shape |
|---|---|
| Cloudinary | https://res.cloudinary.com/<cloud>/image/upload/f_auto,q_auto,c_limit,w_800/<public_id> |
| imgix | https://<source>.imgix.net/<path>?auto=format,compress&w=800&fit=max |
| Mux poster | https://image.mux.com/<playback_id>/thumbnail.webp?width=640&time=5 |
| Mux stream | https://stream.mux.com/<playback_id>.m3u8 (+ ?token= when signed) |
Build URLs only through one helper with a fixed width ladder (e.g. 320, 640, 960, 1280,
1920) and named presets (avatar, card, hero). Snap requested widths up to the ladder.
Stop transformation abuse (every new variant costs a transform and storage):
- Cloudinary: enable Strict Transformations, allow only named or eager transformations, and
generate signed delivery URLs (
sign_url: true, which inserts ans--...--component). - imgix: enable Secure URLs on the source and sign every URL. The signature is an MD5 hex of
the token plus the path plus the query string, appended as
s=. Use@imgix/js-corewithsecureURLTokenserver-side, or precompute URLs at render time; never ship the token.
import ImgixClient from "@imgix/js-core";
const imgix = new ImgixClient({ domain: "acme.imgix.net", secureURLToken: process.env.IMGIX_SECURE_TOKEN });
const src = imgix.buildURL("products/123.jpg", { auto: "format,compress", w: 800, fit: "max" });
const srcset = imgix.buildSrcSet("products/123.jpg", { auto: "format,compress" }, { widths: [320, 640, 960, 1280] });
Responsive markup (srcset, sizes, fetchpriority for the LCP image, explicit
dimensions) is covered in image-optimization; this skill produces the URLs.
5. Private and paid media
- Mux: create a signing key once (Mux dashboard or API) and store its ID and base64 private
key. Mint an RS256 JWT per viewer with
sub= playback ID,aud=v(video),t(thumbnail),g(GIF), ors(storyboard),expshort (minutes to hours), andkid= signing key ID. Each audience needs its own token.
const playbackToken = await mux.jwt.signPlaybackId(playbackId, { type: "video", expiration: "2h" }); // uses MUX_SIGNING_KEY / MUX_PRIVATE_KEY env
const thumbToken = await mux.jwt.signPlaybackId(playbackId, { type: "thumbnail", expiration: "2h" });
// <mux-player playback-id="..." playback-token="..." thumbnail-token="..."></mux-player>
- Authorize before minting: check the viewer owns or purchased the asset, then issue the token from an authenticated endpoint. Tokens in URLs are shareable until they expire, so keep expiry short and add playback restrictions (allowed referrer domains) where supported.
- Cloudinary: upload private originals with
type: "authenticated"(orprivate) and serve signed URLs, or token-based access for time-limited links. Publicuploadtype plus an unguessablepublic_idis not access control.
6. Lifecycle and cost
- On user or content deletion, delete at the provider (
cloudinary.uploader.destroy(id, { invalidate: true }),mux.video.assets.delete(assetId)) and remove derived variants; log the deletion for DSAR records. - Strip EXIF and GPS metadata from user photos on delivery; verify it on a real phone photo.
- Watch usage dashboards monthly: Cloudinary transformations and bandwidth credits, imgix origin images and bandwidth, Mux encoding, storage, and delivered minutes. Alert on spikes.
- Set cache headers long (
immutable) for versioned URLs; change the URL (version segment) rather than purging.
Gotchas
- A bare
w_withoutc_limit/fit=maxupscales small originals into blurry, larger files. q_auto/auto=compresson PNG screenshots with text can produce artifacts; use a preset with higher quality for UI screenshots.- HLS needs a player on Chrome/Firefox desktop; use
mux-player, hls.js, or Video.js, not a bare<video src=".m3u8">(Safari only). - Mux webhooks for an upload arrive before the asset is playable; gate the UI on
ready. - Signing changes the URL; caches and CDNs treat signed and unsigned variants separately, so switch all callers at once.
- Webhook endpoints behind body-parsing middleware break signature checks; mount raw body first.
Verification
- Tamper with a Cloudinary or imgix URL (change
w_800tow_799): strict/signed setup returns 401/403, not a new variant. - Request a signed Mux stream without a token and with an expired token: both fail; a fresh token plays. Thumbnail without its own token fails.
- POST a webhook with a modified body or a 10-minute-old timestamp: rejected. Replay a valid one: asset row updated once.
- Upload a file over the preset size limit and a disallowed format: rejected by the provider.
- Delete a test user: provider assets return 404 after invalidation propagates.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.