Every smartphone photo you take embeds precise metadata inside the file: GPS latitude and longitude, camera serial numbers, lens focal lengths, and timestamps. When users look for an online tool to check or clean this data before sharing an image, 95% of web utilities ask them to upload their private photo to an external backend server.
From a privacy standpoint, uploading a sensitive photo to a remote server just to strip a few kilobytes of metadata completely defeats the purpose.
The common frontend alternative—loading the image into an HTML5 <canvas> and calling canvas.toBlob('image/jpeg')—is equally flawed. A canvas re-encodes the image, which introduces compression artifacts, alters the color profile, and blows away the original image quality.
While engineering ScanMetadata, we designed the parser and scrubber to run 100% client-side directly in the browser's memory using ArrayBuffer and DataView. Here is how JPEG binary markers work and how to strip EXIF data losslessly without touching a single pixel.
1. Anatomy of a JPEG: Where EXIF Actually Lives
A JPEG file is not a continuous block of pixels; it is organized into sequential binary blocks called segments, each starting with a two-byte marker (0xFF followed by a marker code):
-
0xFFD8— SOI (Start of Image): The first two bytes of every valid JPEG. -
0xFFE1— APP1 Segment: Where EXIF metadata (camera specs, GPS coordinates, thumbnail) is stored. -
0xFFE0— APP0 Segment: Standard JFIF header. -
0xFFDB— DQT: Quantization tables. -
0xFFC0/0xFFC2— SOF: Frame header (image width, height, bit depth). -
0xFFDA— SOS (Start of Scan): The beginning of the actual compressed image payload. -
0xFFD9— EOI (End of Image): The trailing two bytes marking the file termination.
Immediately following any segment marker (except SOI, EOI, and SOS), the next two bytes represent the length of that entire segment (including those two length bytes themselves, stored in Big-Endian format).
2. Reading Binary Markers with DataView
To inspect metadata safely without reading entire multi-megabyte files at once, we use the File API with a FileReader or file.arrayBuffer() slice and inspect it through a DataView:
export async function locateExifSegment(file) {
// Read only the first 128KB to locate headers quickly
const slice = await file.slice(0, 131072).arrayBuffer();
const view = new DataView(slice);
// Validate SOI Marker (0xFFD8)
if (view.getUint16(0, false) !== 0xffd8) {
throw new Error("Invalid JPEG: Missing SOI marker");
}
let offset = 2;
const length = view.byteLength;
while (offset < length - 4) {
const marker = view.getUint16(offset, false);
// Stop if we hit Start of Scan (SOS) - image data starts here
if (marker === 0xffda || marker === 0xffd9) {
break;
}
// 0xFFE1 is the APP1 / EXIF Marker
if (marker === 0xffe1) {
const segmentLength = view.getUint16(offset + 2, false);
return {
hasExif: true,
startOffset: offset,
totalLength: segmentLength + 2 // includes the 2-byte marker itself
};
}
// Move to the next segment: marker (2 bytes) + segment length
const segmentLength = view.getUint16(offset + 2, false);
offset += 2 + segmentLength;
}
return { hasExif: false, startOffset: -1, totalLength: 0 };
}
3. Parsing the TIFF Header and Endianness
Inside the 0xFFE1 segment, the payload starts with the ASCII string "Exif\0\0" (6 bytes), immediately followed by the TIFF header.
The first two bytes of the TIFF header indicate byte alignment (Endianness):
-
0x4949("II"): Intel format (Little-Endian) -
0x4D4D("MM"): Motorola format (Big-Endian)
Reading values with the wrong endianness corrupts the tag pointers and GPS coordinates. Here is how we extract whether the file uses Little-Endian:
export function inspectTiffHeader(view, exifStartOffset) {
// Offset + 4 bytes (marker + length) + 6 bytes ("Exif\0\0")
const tiffOffset = exifStartOffset + 10;
const endianTag = view.getUint16(tiffOffset, false);
let isLittleEndian;
if (endianTag === 0x4949) {
isLittleEndian = true; // Intel
} else if (endianTag === 0x4d4d) {
isLittleEndian = false; // Motorola
} else {
throw new Error("Invalid TIFF header format");
}
// The next 2 bytes must be the constant 42 (0x002A)
const magic = view.getUint16(tiffOffset + 2, isLittleEndian);
if (magic !== 42) {
throw new Error("Malformed TIFF header");
}
// First Image File Directory (IFD0) offset
const ifd0Offset = view.getUint32(tiffOffset + 4, isLittleEndian);
return { tiffOffset, isLittleEndian, ifd0Offset };
}
4. Lossless Stripping via Buffer Slicing (No Canvas)
Instead of passing the file into an HTML canvas and re-compressing the pixels, we can strip the metadata with zero quality loss by slicing the ArrayBuffer around the 0xFFE1 segment:
export async function stripExifLossless(file) {
const buffer = await file.arrayBuffer();
const view = new DataView(buffer);
// Verify JPEG header
if (view.getUint16(0, false) !== 0xffd8) {
return file; // Not a JPEG, return untouched
}
const chunks = [];
let cursor = 0;
let offset = 2;
// Add the initial SOI marker
chunks.push(buffer.slice(0, 2));
cursor = 2;
while (offset < buffer.byteLength - 4) {
const marker = view.getUint16(offset, false);
// Hit SOS or EOI - copy the remainder of the compressed image
if (marker === 0xffda || marker === 0xffd9) {
break;
}
const segmentLength = view.getUint16(offset + 2, false);
const totalSegmentBytes = 2 + segmentLength;
// If marker is APP1 (EXIF) or APP2 (FlashPix/ICC profiles if desired), omit it
if (marker === 0xffe1) {
// Push everything up to this marker
if (offset > cursor) {
chunks.push(buffer.slice(cursor, offset));
}
// Skip the entire EXIF block without copying
cursor = offset + totalSegmentBytes;
}
offset += totalSegmentBytes;
}
// Append all remaining bytes (raw image data through EOI)
if (cursor < buffer.byteLength) {
chunks.push(buffer.slice(cursor));
}
// Construct a sanitized Blob with original MIME type
return new Blob(chunks, { type: file.type || "image/jpeg" });
}
Why This Architecture Matters:
- 100% Lossless: Not a single DCT coefficient or quantization table is re-encoded. The resulting photo is mathematically identical in image quality, just lighter by the size of the removed tags.
- Instant Performance: Parsing or removing headers takes 2–5 milliseconds even on 40MB RAW/JPEG smartphone exports because the CPU only slices memory buffers.
- Absolute Privacy: User files never leave the browser's JavaScript sandbox. There are no tracking scripts, no database logs, and no S3 buckets.
You can inspect full EXIF payloads, check GPS location tags, and test the zero-upload sanitizer live at ScanMetadata.
The complete source code and binary parsing implementation is open-sourced on GitHub.
Top comments (0)