Stripping EXIF Metadata Before Upload

A canvas re-encode drops every metadata segment for free but also drops the orientation tag, so you must bake rotation in with createImageBitmap(file, { imageOrientation: "from-image" }) first; if you cannot afford the generation loss, walk the JPEG’s marker chain instead and rewrite the file without its APP1 segment, leaving the compressed scan data byte-identical.

This article belongs to client-side media preprocessing within frontend UX, chunking and progress tracking. A photo straight off an iPhone carries a GPS IFD accurate to a few metres, a serial number, the lens, and a 160×120 thumbnail that sometimes still shows the uncropped original. None of that belongs on your CDN.

When to use this approach

  • Canvas re-encode — you are already resizing or recompressing. Metadata removal is then a side effect of work you were doing anyway, so pay nothing extra. This is the path in resizing images in the browser with canvas.
  • Segment rewrite — the upload must preserve the original pixels exactly: print orders, evidence, anything where a second JPEG generation is unacceptable, or any case where a checksum has to match the photographer’s copy of the image data.
  • Neither — you control the ingest pipeline end to end and can strip during derivative generation with a Sharp-based image pipeline. Client-side stripping is a privacy improvement for the bytes in flight, never a substitute for the server pass.

Prerequisites

  1. A File from an input, a drop zone or the clipboard, plus a real type check — file.type is an OS guess, so confirm the container with magic-byte detection before you start parsing markers.
  2. createImageBitmap with the imageOrientation option: Chrome 89+, Firefox 77+, Safari 15+.
  3. OffscreenCanvas.convertToBlob for the encode path (Safari 16.4+); on older Safari fall back to a detached <canvas> and toBlob.
  4. TypeScript with lib: ["DOM", "DOM.Iterable", "ES2022"]. No dependencies — everything below is standard-library only.

What is actually in the file

EXIF is not a header field. It is a complete TIFF file — header, image file directories, tag tables and all — wrapped in a JPEG APP1 segment that sits between the start-of-image marker and the quantisation tables. Removing “the metadata” means removing that segment and its neighbours, and knowing which neighbours to keep.

Anatomy of a JPEG marker chain and the APP1 EXIF segment A JPEG shown as a sequence of marker segments, with the APP1 segment expanded to reveal the Exif identifier, TIFF header, IFD0 and the GPS IFD pointer. What a camera JPEG is made of SOI FF D8 APP1 Exif FF E1 — drop this APP2 ICC FF E2 — keep DQT / DHT / SOF0 coding tables SOS + scan data 99% of the bytes EOI FF D9 FF E1 length, 2 B big-endian Exif\0\0 45 78 69 66 00 00 TIFF header MM or II, then 42 offset to IFD0 IFD0 Make, Model, 0x0112 Orientation GPS IFD pointer tag 0x8825 lat/lon/alt/timestamp IFD1 follows IFD0 and holds the embedded thumbnail — deleted with the same segment. The colour profile lives in APP2, not APP1, so it survives a surgical strip.
Everything privacy-sensitive sits inside one APP1 segment; the ICC profile you want to keep sits in a different one.

The GPS block is reached through tag 0x8825 in IFD0, whose value is an offset to a second directory. That indirection is why a naive “search for the string GPS and blank it” never works — there is no string, only a 4-byte offset into the TIFF block.

Implementation

The canvas path. It re-encodes, so every marker segment in the output is written fresh by the browser’s encoder and no camera metadata can survive.

// strip-by-reencode.ts
export interface ReencodeOptions {
  maxEdge?: number;   // longest side in CSS pixels; 0 keeps the native size
  quality?: number;   // 0–1, JPEG only
  type?: "image/jpeg" | "image/webp";
}

export async function stripByReencode(file: File, opts: ReencodeOptions = {}): Promise<File> {
  const { maxEdge = 0, quality = 0.85, type = "image/jpeg" } = opts;

  // "from-image" applies the EXIF Orientation tag while decoding, so the bitmap
  // is already upright and bitmap.width/height are the *displayed* dimensions.
  const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });

  const scale = maxEdge > 0 ? Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)) : 1;
  const width = Math.max(1, Math.round(bitmap.width * scale));
  const height = Math.max(1, Math.round(bitmap.height * scale));

  const canvas = new OffscreenCanvas(width, height);
  // alpha:false avoids a premultiply round trip; JPEG has no alpha channel anyway.
  const ctx = canvas.getContext("2d", { alpha: false });
  if (!ctx) throw new Error("2d context unavailable — is the tab backgrounded on iOS?");
  ctx.drawImage(bitmap, 0, 0, width, height);
  bitmap.close(); // release the decoded surface immediately, not at the next GC

  const blob = await canvas.convertToBlob({ type, quality });

  // The filename is metadata too: IMG_20240712_143205.jpg leaks a timestamp,
  // and lastModified leaks another. Both are replaced here.
  const ext = type === "image/webp" ? "webp" : "jpg";
  return new File([blob], `upload-${crypto.randomUUID()}.${ext}`, {
    type,
    lastModified: Date.now(),
  });
}

Line-by-line on the critical parameters

  • imageOrientation: "from-image" is the whole reason this function is safe. The spec default changed to "from-image" only in 2021 and older engines defaulted to "none"; passing it explicitly means one branch fewer to reason about. With it set, bitmap.width is 4032 for a portrait iPhone photo whose SOF0 says 3024 — the rotation has already happened.
  • bitmap.close() matters at photo sizes. A 48 MP bitmap holds roughly 192 MB of RGBA; leaving three of them alive while a batch uploads is how a mid-range Android tab gets killed.
  • convertToBlob({ quality }) is ignored for image/png and honoured for JPEG and WebP. At 0.85 a 12 MP photo lands around 1.4 MB against a 4.2 MB original — most of that saving is recompression, not metadata.
  • The new File drops lastModified. Keeping the original value re-attaches a timestamp you just spent effort removing, and it is the field most people forget.
  • If you also hash the upload, hash this file, not the input. Browser checksums computed before the transform will not match what the storage bucket receives.

What you lose: the ICC profile. A canvas 2D context is sRGB by default, so a Display P3 photo from an iPhone gets converted and the saturated reds visibly flatten. Pass { colorSpace: "display-p3" } to both getContext and convertToBlob if that matters, and accept that the profile is now implicit rather than embedded.

Surgical removal: walking the marker chain

When the pixels must not change, parse instead of decode. The scan data is copied verbatim, so the output is bit-identical to the input from the SOS marker onward.

// strip-jpeg-segments.ts
const APP1 = 0xe1, APP2 = 0xe2, APP12 = 0xec, APP13 = 0xed, COM = 0xfe, SOS = 0xda;

export interface StripOptions {
  keepIcc?: boolean;      // default true — APP2 ICC_PROFILE survives
  orientation?: number;   // re-inject Orientation (1 = already upright)
  copyright?: string;     // re-inject an ASCII Copyright tag
}

/** Builds a minimal APP1 payload: TIFF header + one IFD with up to two tags. */
function buildExifPayload(opts: StripOptions): Uint8Array {
  const entries: Array<{ tag: number; type: number; count: number; inline?: number; data?: Uint8Array }> = [];
  if (opts.orientation !== undefined) {
    entries.push({ tag: 0x0112, type: 3, count: 1, inline: opts.orientation }); // SHORT
  }
  if (opts.copyright) {
    const ascii = new TextEncoder().encode(opts.copyright.replace(/[^\x20-\x7e]/g, "?") + "\0");
    entries.push({ tag: 0x8298, type: 2, count: ascii.length, data: ascii });   // ASCII
  }
  const ifdEnd = 8 + 2 + entries.length * 12 + 4;
  const heap = entries.reduce((n, e) => n + (e.data && e.data.length > 4 ? e.data.length + (e.data.length % 2) : 0), 0);
  const tiff = new Uint8Array(ifdEnd + heap);
  const view = new DataView(tiff.buffer);
  tiff[0] = 0x49; tiff[1] = 0x49;      // "II" — little-endian TIFF
  view.setUint16(2, 42, true);          // the magic 42 that confirms the byte order
  view.setUint32(4, 8, true);           // IFD0 starts 8 bytes into the TIFF block
  view.setUint16(8, entries.length, true);
  let at = 10, heapAt = ifdEnd;
  for (const e of entries) {
    view.setUint16(at, e.tag, true);
    view.setUint16(at + 2, e.type, true);
    view.setUint32(at + 4, e.count, true);
    if (e.inline !== undefined) {
      view.setUint16(at + 8, e.inline, true);   // SHORT sits in the low 2 bytes
      view.setUint16(at + 10, 0, true);
    } else if (e.data!.length <= 4) {
      tiff.set(e.data!, at + 8);
    } else {
      view.setUint32(at + 8, heapAt, true);     // offsets are relative to the TIFF header
      tiff.set(e.data!, heapAt);
      heapAt += e.data!.length + (e.data!.length % 2);
    }
    at += 12;
  }
  view.setUint32(at, 0, true);          // no IFD1 — no thumbnail
  const payload = new Uint8Array(6 + tiff.length);
  payload.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 0); // "Exif\0\0"
  payload.set(tiff, 6);
  return payload;
}

function identifierIs(segment: Uint8Array, ascii: string): boolean {
  for (let i = 0; i < ascii.length; i++) {
    if (segment[4 + i] !== ascii.charCodeAt(i)) return false;
  }
  return true;
}

function shouldDrop(marker: number, segment: Uint8Array, keepIcc: boolean): boolean {
  if (marker === APP1) return true;   // EXIF and XMP both live here
  if (marker === APP2) return !(keepIcc && identifierIs(segment, "ICC_PROFILE"));
  if (marker === APP12) return true;  // "Ducky" — Photoshop save-for-web
  if (marker === APP13) return true;  // Photoshop IRB: IPTC, paths, extra thumbnails
  if (marker === COM) return true;    // free-text comment
  return false;
}

export async function stripJpegMetadata(input: Blob, opts: StripOptions = {}): Promise<Blob> {
  const bytes = new Uint8Array(await input.arrayBuffer());
  if (bytes[0] !== 0xff || bytes[1] !== 0xd8) {
    throw new Error(`not a JPEG: expected SOI FF D8, found ${bytes[0]?.toString(16)} ${bytes[1]?.toString(16)}`);
  }
  const out: Uint8Array[] = [bytes.subarray(0, 2)];

  if (opts.orientation !== undefined || opts.copyright) {
    const payload = buildExifPayload(opts);
    const header = new Uint8Array(4);
    header[0] = 0xff; header[1] = APP1;
    new DataView(header.buffer).setUint16(2, payload.length + 2); // length counts itself
    out.push(header, payload);
  }

  let pos = 2;
  while (pos < bytes.length - 1) {
    if (bytes[pos] !== 0xff) {
      throw new Error(`marker desync at byte ${pos}: expected FF, found ${bytes[pos].toString(16)}`);
    }
    let marker = bytes[pos + 1];
    while (marker === 0xff) marker = bytes[++pos + 1]; // fill bytes are legal padding
    if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
      out.push(bytes.subarray(pos, pos + 2));          // standalone: no length field
      pos += 2;
      continue;
    }
    const length = (bytes[pos + 2] << 8) | bytes[pos + 3];
    const segment = bytes.subarray(pos, pos + 2 + length);
    if (marker === SOS) {
      out.push(segment, bytes.subarray(pos + 2 + length)); // entropy data, copied as-is
      break;
    }
    if (!shouldDrop(marker, segment, opts.keepIcc !== false)) out.push(segment);
    pos += 2 + length;
  }
  return new Blob(out, { type: "image/jpeg" });
}

Three details decide whether this works on real files. The while (marker === 0xff) loop handles fill bytes, which some encoders emit before a marker and which crash naive parsers. Breaking at SOS and copying the remainder in one subarray means progressive JPEGs — several scans, multiple SOS markers — need no special handling and no restart-marker arithmetic. And the segment length is big-endian and includes its own two bytes, so a segment occupies 2 + length bytes in total; getting that off by two shifts every subsequent marker and produces the desync error above.

Call it as stripJpegMetadata(file, { orientation: 1, copyright: "© 2026 Example Ltd" }) when you want the file to carry an explicit upright flag and a rights statement and nothing else. Leave both out and the output has no APP1 at all.

Which path to pick

Canvas re-encode versus JPEG segment rewrite A comparison table of seven properties across the canvas re-encode approach and the segment rewrite approach. Re-encode or rewrite Property Canvas re-encode Segment rewrite Compressed pixel data second generation byte-identical Typical size change, 12 MP 4.2 MB to 1.4 MB 4.2 MB to 4.17 MB ICC colour profile lost, output is sRGB kept by default Orientation tag baked into pixels must re-inject as 1 GPS IFD and thumbnail gone gone Formats handled anything decodable one parser per format Cost per photo 380 ms, 190 MB peak 15 ms, 2x file size
The rewrite is thirty times cheaper and lossless; the re-encode is the only option once you leave JPEG.

The 190 MB figure is the decoded bitmap plus the canvas backing store, and it is why batch processing must be sequential. Six photos in parallel is a tab crash on any phone; queue them one at a time behind the same concurrency limiter you use for payload sizing on mobile uploads.

Getting orientation wrong in two directions

Orientation is the only EXIF tag whose removal changes what the user sees. Tag 0x0112 value 6 means “the camera was rotated 90° clockwise, so rotate the pixels 90° clockwise when displaying”. Strip it without baking it in and every viewer shows the photo on its side.

Three outcomes when handling the EXIF orientation tag A source photo with orientation six branches into three handling strategies, two of which display incorrectly and one of which is correct. Source photo: Orientation = 6 3024x4032 stored as 4032x3024 Strip only imageOrientation: "none" or APP1 simply deleted sideways everywhere Bake and keep the tag pixels rotated once tag still says 6 rotated twice, 180 off Bake, then reset to 1 from-image on decode no tag, or tag = 1 correct everywhere Servers that read dimensions from SOF0 see 4032x3024 for all three outputs. Only the middle column is silently wrong in half your clients.
Baking rotation and resetting the tag is the only combination that survives both EXIF-aware and EXIF-blind viewers.

This is also why the dimensions you record server-side must come from the decoded image, not from SOF0 — see storing image dimensions and duration metadata.

PNG and MP4 are different problems

PNG has no EXIF segment. It has a chunk stream: an 8-byte signature followed by length(4) | type(4) | data | crc(4) records. Text lives in tEXt, zTXt and iTXt chunks (Photoshop writes XMP into iTXt), a modern camera may write an eXIf chunk, and tIME records the last modification. Because each chunk carries its own CRC, you can drop chunks without recomputing anything:

const PNG_KEEP = new Set(["IHDR", "PLTE", "tRNS", "IDAT", "IEND", "sRGB", "gAMA", "cHRM", "iCCP"]);

export async function stripPngChunks(input: Blob): Promise<Blob> {
  const bytes = new Uint8Array(await input.arrayBuffer());
  const out: Uint8Array[] = [bytes.subarray(0, 8)]; // 89 50 4E 47 0D 0A 1A 0A
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  let pos = 8;
  while (pos + 8 <= bytes.length) {
    const length = view.getUint32(pos);
    const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]);
    const total = 12 + length;
    if (PNG_KEEP.has(type)) out.push(bytes.subarray(pos, pos + total));
    pos += total;
    if (type === "IEND") break;
  }
  return new Blob(out, { type: "image/png" });
}

MP4 is where honest advice beats clever code. Location and device data sit in moov/udta — Apple writes ISO 6709 coordinates into a ©xyz atom, Android uses the same convention — plus moov/meta/ilst key-value pairs and a creation time in mvhd. Removing an atom means patching the size field of every ancestor atom up to moov, and moov may sit after mdat, so the offsets in stco/co64 shift too. Doing that correctly in a browser means a full re-mux — demux with a WebCodecs-based pipeline and write a fresh container — which is a far larger commitment than the twenty lines a JPEG needs. For anything short of that, strip on ingest with ffmpeg -i in.mp4 -map_metadata -1 -c copy out.mp4, which rewrites the container without touching a single encoded frame.

Configuration gotchas

InvalidStateError: Failed to execute 'createImageBitmap' on 'Window': The source image could not be decoded. The user picked “Keep Originals” in iOS Photos settings and handed you a HEIC. Chrome and Firefox on desktop cannot decode it; Safari can. The same message appears for a truncated upload from a flaky camera roll sync. Detect the container first and route HEIC to a server-side conversion rather than failing the whole batch.

A silently blank or black canvas, with no exception. WebKit caps total canvas area — historically 16,777,216 pixels on iOS — and drawImage beyond it succeeds while producing nothing. A 48 MP photo is 8064×6048, which is three times the cap. Check width * height <= 16_777_216 before allocating and downscale to fit; the resize you were going to do anyway usually solves it.

marker desync at byte 4: expected FF, found 89. The segment walker was handed a PNG with a .jpg extension, which is exactly the mismatch file.type will not catch. This is also the failure you get if you slice a file with Blob.slice and forget that the walker needs the whole buffer.

Everything looks stripped, but the CDN still serves GPS. Some upload proxies and image services re-attach metadata from a sidecar, and any derivative you generate from an unstripped original brings it back. Re-strip on the server during derivative generation, and validate what you actually stored with server-side dimension and pixel-bomb checks in the same pass.

Verification

Prove the segment is gone with a hex dump of the first bytes. An untouched camera JPEG starts with FF D8 FF E1; a stripped one starts with the next surviving marker:

xxd -l 16 -g 1 original.jpg
# 00000000: ff d8 ff e1 5b 2a 45 78 69 66 00 00 4d 4d 00 2a  ....[*Exif..MM.*

xxd -l 16 -g 1 stripped.jpg
# 00000000: ff d8 ff e2 02 1c 49 43 43 5f 50 52 4f 46 49 4c  ......ICC_PROFIL

exiftool -a -G1 -s stripped.jpg | grep -Ei 'gps|make|model|serial'
# (no output — exit status 1)

Then prove the pixels are untouched, which is the entire point of the surgical path:

cmp <(djpeg original.jpg) <(djpeg stripped.jpg) && echo "pixel data identical"
# pixel data identical

In the browser, assert it without leaving the page:

export async function assertNoExif(blob: Blob): Promise<void> {
  const head = new Uint8Array(await blob.slice(0, 4).arrayBuffer());
  if (head[2] === 0xff && head[3] === 0xe1) {
    const app1 = new Uint8Array(await blob.slice(4, 16).arrayBuffer());
    const tag = String.fromCharCode(...app1.subarray(2, 6));
    throw new Error(`APP1 survived: identifier "${tag}"`);
  }
  const round = await createImageBitmap(blob);
  console.log(`clean: ${round.width}x${round.height}, ${(blob.size / 1024).toFixed(0)} KB`);
  round.close();
}

The re-decode at the end is not ceremony. A parser bug that miscounts one segment length produces a file that still passes the marker check and fails to render — decoding it once catches that immediately.

Frequently Asked Questions

Does stripping EXIF make the image smaller?

Barely. A typical EXIF block with an embedded thumbnail is 20–60 KB against a 4 MB photo, so a surgical strip saves under 1%. If the file size is what you care about, the re-encode path is doing the work and the metadata removal is incidental.

Can I keep the copyright tag but delete everything else?

Yes, but not by editing in place — rewriting one tag inside an IFD means fixing every offset that follows it. Build a fresh APP1 containing only the tags you want, as buildExifPayload does above, and discard the original segment entirely.

Is client-side stripping enough to promise users their location is private?

No. A scripted client, a browser extension, or a plain curl against your presigned URL all bypass it, so the guarantee has to be enforced where you control the code. Treat the browser pass as reducing exposure in transit and repeat it server-side before anything becomes publicly reachable.

What happens to XMP and IPTC data?

XMP rides in a second APP1 segment identified by an XML namespace URI rather than Exif\0\0, and IPTC rides in the Photoshop resource block in APP13. Dropping every APP1 and APP13 removes both, which is why the walker’s drop list is broader than “the EXIF one”.

Does the ICC profile need to survive?

Only if your images are wide-gamut and your delivery path is colour-managed end to end. If you render everything through a resizer that outputs sRGB anyway, dropping APP2 saves a few kilobytes and changes nothing visible.