Stripping EXIF before upload and backfilling the photos already in the bucket


A holiday photo can leak GPS, the camera’s serial number, the software it was edited with, and the exact timestamp of when you were not home. Cloudflare Images will happily serve all of that if you let it.

This is part of the photography portfolio series . It covers the browser-side EXIF strip that runs before every upload on alex.edestudio.us and jamie.edestudio.us , and the one-off backfill script that cleans up the photos already in Cloudflare Images before the strip existed.


The obvious leaks first:

  • GPS coordinates. Phones embed these by default. A photo posted to a public gallery is a public location pin.
  • Capture timestamp. Combined with location, this tells someone exactly when you were where you were.

The less obvious ones:

  • Camera make, model, and serial number. Useful for fingerprinting a photographer across platforms. A serial number is a permanent identifier even if you change your username.
  • Software version. “Lightroom 14.2.1” is a small surface area, but it tells an adversary which CVE backlog to test against if they want to send you a malicious file.
  • Original filename and IPTC metadata. Often contains a client name, an event name, or a personal nickname the photographer never intended to publish.

I want all of that gone before the photo leaves the browser. Server-side stripping after the fact is fine as a defence in depth, but the threat model also includes “the file passes through some network or storage hop where I don’t fully control what’s logged”. Strip early, strip in the browser, ship the clean file.


The strip is format-aware. JPEG, PNG, WebP, and GIF each have their own segment layout, so each gets its own parser in src/lib/exifStrip.ts. SVG passes through untouched because SVGs don’t carry EXIF (they can carry other PII, but that’s a different post).

For JPEG, the segment table tells the whole story:

JPEG segment Hex marker What it carries Strip behaviour
SOI / EOI D8 / D9 Start and end of image Keep
APP0 (JFIF) E0 JFIF header Keep
APP1 (EXIF) E1 EXIF, GPS, orientation Drop
APP2 (ICC) E2 Colour profile Keep
APP13 ED IPTC, Photoshop IRB Drop
COM FE Free-text comment Drop
SOF, DHT, DQT, SOS various Actual image data Keep

Three markers go (E1, ED, FE), everything else stays. ICC stays because dropping the colour profile would shift the image’s colours; JFIF stays because it’s the format’s own structural header.

PNG drops tEXt, iTXt, zTXt, eXIf, and tIME chunks. WebP drops the EXIF and XMP chunks (yes the trailing space is part of the FourCC). GIF drops Comment, Application, and Plain Text extensions but keeps Graphics Control extensions so animations still work.

Orientation goes with the EXIF block. Cloudflare Images bakes rotation into the rendered pixels at upload time, so an iPhone portrait photo still displays portrait even though the orientation tag is gone. That detail isn’t in the Cloudflare Images quickstart docs; you find it in the image-resizing type definitions (“rotation and embedded color profiles are always applied ‘baked in’ into the actual pixels”). I tested it with a portrait photo just to be sure before shipping.


There are libraries for this. I considered three and then wrote my own:

  • piexifjs is JPEG-only and parses the EXIF block to mutate it, which is more work than needed when I just want to drop the segment wholesale.
  • exifr / exif-js are read-focused.
  • A canvas re-encode (draw to canvas, export with toBlob) removes everything but also re-encodes the JPEG and loses quality.

A byte-level segment walk is short. The JPEG stripper is about 50 lines: read the SOI, walk markers, copy or skip each segment based on its hex marker, stop at SOS, return the new buffer. The same shape works for PNG (chunk walk) and WebP (RIFF chunk walk). The whole library is around 250 lines of pure TypeScript and produces byte-identical image data; only the metadata segments are missing.

// src/lib/exifStrip.ts (JPEG, the interesting bit)
const JPEG_DROP_MARKERS = new Set([0xe1, 0xed, 0xfe]);

export function stripJpegMetadata(input: Uint8Array): Uint8Array {
  if (input.length < 4 || input[0] !== 0xff || input[1] !== 0xd8) {
    throw new Error("Not a JPEG: missing SOI marker");
  }

  const chunks: Uint8Array[] = [new Uint8Array([0xff, 0xd8])];
  let i = 2;

  while (i < input.length) {
    // ... walk markers, drop any segment whose marker is in JPEG_DROP_MARKERS,
    //     copy everything else verbatim including the entropy-coded data after SOS.
  }
}

The dispatcher at the top of the file picks the right parser by MIME type and returns a new File object the rest of the upload pipeline can use unchanged.

The tests in exifStrip.test.ts construct minimal byte-level-valid JPEGs with each combination of marker segments and assert that the right ones survive. That gives me confidence that adding a new dropped marker later won’t accidentally also drop colour profiles.


Inside the upload pipeline, between minting the upload URL and posting the file:

const { uploadURL, id } = await requestUploadUrl();
const cleaned = await stripFileMetadata(it.file);   // <-- here
await uploadWithProgress(uploadURL, cleaned, ...);
await createPhoto({ cf_image_id: id, /* ... */ });

One line. The cleaned File carries the same name and lastModified as the original (the user expects “IMG_2456.jpg” to still be IMG_2456.jpg in the admin grid), but only the bytes the user would actually want to publish.


By the time I added the strip, I already had a few hundred photos uploaded the old way. They had full EXIF in Cloudflare Images . I needed a one-time pass to fetch each original, strip it, re-upload via Direct Creator Upload , repoint the D1 row, and delete the old image.

The script is scripts/backfill-exif-strip.ts, called via npm run backfill:exif -- --db photography (and --db photography-jamie for the other site). It is idempotent and resumable: a metadata_stripped_at column on the photos table tracks completion, and the script’s first query is WHERE metadata_stripped_at IS NULL. Rerunning skips anything already done.

The flow per photo:

const { bytes, contentType } = await fetchCfBlob(account, token, row.cf_image_id);
const cleaned = stripByContentType(bytes, contentType);
if (!cleaned) { /* unsupported format, skip and log */ continue; }

const { uploadURL, id: newId } = await createDirectUpload(account, token);
await uploadBytes(uploadURL, cleaned, contentType, `${row.cf_image_id}.${ext}`);

await d1Query(dbName,
  `UPDATE photos SET cf_image_id = '${newId}', metadata_stripped_at = datetime('now') WHERE id = ${row.id}`,
  wranglerConfig,
);

await deleteCfImage(account, token, row.cf_image_id);

Three places this script can leave a mess if it crashes mid-photo:

  1. After re-upload, before D1 update. A new clean image exists in Cloudflare Images but nothing points at it. The script tracks these as orphans and prints them at the end for manual cleanup.
  2. After D1 update, before old delete. The site is already serving the new clean image; the old EXIF-laden one is still in Cloudflare Images, no longer referenced. Same orphans list catches it.
  3. D1 update itself fails. The new image becomes an orphan immediately and the row stays pointing at the old, EXIF-laden image. This is the worst case but also the rarest, and re-running the script is safe because metadata_stripped_at is still NULL.

I share the same code (stripJpegMetadata, stripPngMetadata, etc.) between the browser strip and this backfill, so any bug fix in the stripper benefits both paths.


The backfill works against two D1 databases, one per site. Running it against the second one consistently crashed Node with a libuv assertion in uv_async_send. The first database always succeeded.

The root cause is that tsx (the TypeScript runner) keeps open async handles that are inherited by every child process spawned with child_process.spawn. When Wrangler ran the D1 query and returned actual rows, the slightly longer async cycle was just enough to race with Wrangler’s own cleanup. The first database returned zero rows-to-process on the second run, the second database returned rows, the race materialised.

The fix is in the code comment in backfill-exif-strip.ts and is not subtle:

const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", psCmd], {
  stdio: "ignore",   // ← no pipe handles whatsoever connecting tsx to this process chain
  windowsHide: true,
});

stdio: "ignore" instead of piping; Wrangler’s output is redirected to a temp file via PowerShell instead of through tsx’s stdio; the temp file is read after the child closes. The Wrangler output never touches a handle in tsx’s process table.

This is a one-off ops script, not production code, so the workaround is acceptable. But it’s the kind of bug that makes a “simple” backfill script eat an afternoon, and worth knowing about if you script Wrangler invocations from tsx on Windows.


  • HEIC support. The strip dispatcher returns UnsupportedFormatError for image/heic. Phones still produce HEIC by default in some cases, and while I haven’t seen many, supporting it would mean either writing a HEIC segment walker (HEIC is a container around HEVC, with its own metadata layout) or doing a canvas re-encode for that format only. Neither is hard; I just haven’t done it.
  • Server-side double-strip. Cloudflare Images already strips EXIF on its variant delivery (because metadata: "none" is set on every variant), so the public site is safe even if the browser strip is somehow bypassed. But the original stored in Cloudflare Images still contains whatever the upload sent. A Pages Function that re-runs the strip server-side on every direct-upload save would close that gap. For my threat model the current state is fine; for a higher-stakes site I’d add it.

  1. Building a photography portfolio on Cloudflare’s full stack. The stack overview: Pages, D1, Images, Access, and Workers AI.
  2. Two sites, one codebase. The deploy-pages.mjs indirection, per-site D1, and Gitea CI.
  3. Cloudflare Images flexible delivery and retina srcSet. Variant setup, srcSet generation, and the original-download endpoint.
  4. Direct Creator Upload from the browser. Why a Pages Function should not proxy your image bytes, and what the three-request handshake looks like in DevTools.
  5. Stripping EXIF before upload and backfilling existing photos. What gets stripped, what stays, and the backfill script. (this post)
  6. AI-assisted captions, alt text, and tags. The Workers AI vision-model dispatcher and where it helps versus where it confidently invents.

The strip is invisible to users of either site. The effect is: every image you can download has nothing in its EXIF block except what Cloudflare Images itself adds.

Right-click any image, save it, open it in an EXIF viewer like exif.tools or exiftool. The metadata you get back should be a handful of fields, not pages of them.

If you run a site that handles user-uploaded images and you have not done the strip yet, I would love to hear what stopped you. Drop a note on LinkedIn .

×
Page views: