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.

  • namemedia-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

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.

~/.claude/skills/media-cdn/SKILL.md

--- name: media-cdn description: Delivers user and product media through Cloudinary, imgix, and Mux - signed direct uploads and upload widgets, URL transformations with… license: MIT ---

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, Mux asset_id, playback_id) and your own ownership row. Never derive ownership from a URL the client sends back.

Choose the service

NeedDefaultNotes
Images (and some video) uploaded by users, transformed on the flyCloudinaryUpload API, widget, transforms, moderation add-ons
Images already in your S3/GCS bucket, just need resizing and formatsimgix (or Cloudflare Images)Points at your origin; no upload pipeline to adopt
Video playback with adaptive streaming, players, analyticsMuxEncodes to HLS, signed playback, direct uploads
Static marketing images in the repoFramework image component, no media CDNSee image-optimization
Raw file storage and presigned S3 downloadsS3 or R2 directlySee 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

ProviderVerifyEvents to handle
MuxMux-Signature: t=<ts>,v1=<hex>; HMAC-SHA256 over "<ts>." + rawBody with the endpoint secret; 5-minute tolerancevideo.upload.asset_created, video.asset.ready, video.asset.errored, video.asset.deleted
CloudinaryX-Cld-Signature + X-Cld-Timestamp; hex SHA-1 (or SHA-256) of rawBody + timestamp + api_secret; reject stale timestampsupload, 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.

ServiceURL shape
Cloudinaryhttps://res.cloudinary.com/<cloud>/image/upload/f_auto,q_auto,c_limit,w_800/<public_id>
imgixhttps://<source>.imgix.net/<path>?auto=format,compress&w=800&fit=max
Mux posterhttps://image.mux.com/<playback_id>/thumbnail.webp?width=640&time=5
Mux streamhttps://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 an s--...-- 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-core with secureURLToken server-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), or s (storyboard), exp short (minutes to hours), and kid = 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" (or private) and serve signed URLs, or token-based access for time-limited links. Public upload type plus an unguessable public_id is 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_ without c_limit/fit=max upscales small originals into blurry, larger files.
  • q_auto/auto=compress on 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_800 to w_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.

esc