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
- A
Filefrom an input, a drop zone or the clipboard, plus a real type check —file.typeis an OS guess, so confirm the container with magic-byte detection before you start parsing markers. createImageBitmapwith theimageOrientationoption: Chrome 89+, Firefox 77+, Safari 15+.OffscreenCanvas.convertToBlobfor the encode path (Safari 16.4+); on older Safari fall back to a detached<canvas>andtoBlob.- 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.
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.widthis 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 forimage/pngand honoured for JPEG and WebP. At0.85a 12 MP photo lands around 1.4 MB against a 4.2 MB original — most of that saving is recompression, not metadata.- The new
FiledropslastModified. 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
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.
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.