Stripping EXIF without re-encoding the JPEG

How Stayput removes location and camera data from photos in the browser, byte for byte, in about 600 lines of TypeScript and no library.

Updated · 5 min read

The problem with the usual answers

There are two common ways to remove EXIF data from a photo, and both have a cost. The first is to re-save it: open it in an editor or pass it through a canvas, then export a new JPEG. The metadata is gone because the encoder never writes it, but the picture has been decoded and compressed again, so it loses a little quality and often grows in size. The second is to upload it to a website that does the same on a server, which removes the location from the file by handing it to someone else first.

There is a third way. A photo file is a container: a sequence of labelled blocks, one of which holds the compressed picture and others that hold metadata. If you can read the container, you can copy every block except the metadata ones. The picture bytes are never decoded, so the result is pixel-identical and slightly smaller. That is how the Stayput EXIF remover works, entirely in the browser, and this page walks through it.

What is actually in the file

Each of the three common web formats is a different container, but the idea is the same in all of them:

  • JPEG is a list of segments, each starting with a 0xFF marker byte. APP1 (0xFFE1) holds EXIF when its payload starts with "Exif\0\0", or XMP when it starts with Adobe's namespace URL. APP2 holds the ICC colour profile, APP13 holds IPTC, and 0xFFFE is a comment. The picture itself follows the start-of-scan marker (0xFFDA) and runs to the end of the file.
  • PNG is an eight-byte signature followed by chunks, each with a length, a four-letter type, the data and a CRC. Metadata lives in eXIf, tEXt, zTXt, iTXt (where XMP goes) and tIME; the colour profile is iCCP; the picture is in IDAT.
  • WebP is a RIFF file: "RIFF", a total size, "WEBP", then chunks. EXIF, "XMP " and ICCP chunks hold metadata. An extended VP8X header chunk carries flag bits announcing which of them are present.

The approach, for JPEG

Walk the segments from the start of the file until the start-of-scan marker. For each one, read its two-byte big-endian length, classify it by marker and the first bytes of its payload, and decide whether to keep it. Then copy the kept segments and everything from the scan onward into a new file.

for (const s of segments) {
  const kind = segKind(b, s); // 'EXIF', 'XMP', 'ICC', 'IPTC', 'Comment', 'JFIF'...
  const isMeta = kind !== undefined && kind !== 'JFIF' && kind !== 'Adobe';
  const drop = isMeta && (kind !== 'ICC' || !keepIcc);
  if (drop) {
    removed += s.end - s.start;
    continue;
  }
  parts.push(b.subarray(s.start, s.end));
}
parts.push(b.subarray(scanStart)); // the compressed picture, untouched

JFIF and Adobe segments stay because decoders use them to interpret colour. The ICC profile stays by default for the same reason: it holds no personal data, and dropping it can make a wide-gamut phone photo look washed out. The test suite checks the last 2,000 bytes of the output against the input to prove the scan data is byte-identical.

PNG and WebP

PNG is simpler still: every chunk carries its own length and CRC, so dropping a chunk needs no fix-ups anywhere else. Copy the signature, then every chunk that is not eXIf, tEXt, zTXt, iTXt or tIME (and iCCP, if you asked for the profile to go).

WebP needs two fix-ups. The RIFF header at the start stores the size of everything after it, so it has to be rewritten once the chunks are dropped. And if the file has a VP8X header, its flags byte still claims the EXIF and XMP chunks exist, which some decoders reject. Clear those bits:

if (c.fourcc === 'VP8X') {
  const chunk = b.slice(c.start, c.end);
  let flags = chunk[8]!;
  flags &= ~0x08 & ~0x04;          // EXIF and XMP present
  if (!keepIcc) flags &= ~0x20;    // ICC present
  chunk[8] = flags;
  body.push(chunk);
}
// ...then a new header: "RIFF", payload.length + 4, "WEBP"

The gotchas

Most of the work is in the cases that break the simple version:

  • Orientation. Phones usually store a portrait photo sideways and set an EXIF orientation tag telling viewers to rotate it. Delete EXIF and the photo displays on its side. The remover reads the tag first and offers a choice: bake the rotation into the pixels (the one path that does re-encode) or keep the file lossless and accept the sideways display.
  • Padding and fill bytes. JPEG allows any number of 0xFF fill bytes between segments, and standalone markers (restart markers, SOI) have no length field. Treat them as lengths and you walk off into the image data.
  • RIFF padding. WebP chunks with an odd length are followed by a zero pad byte that is not counted in the chunk's length. Miss it and the next chunk header is read one byte early.
  • XMP in PNG. XMP arrives as an iTXt chunk with the keyword "XML:com.adobe.xmp", so it is dropped along with the other text chunks rather than needing its own rule.
  • Thumbnails. The EXIF block can embed a small JPEG preview in its second directory. Editors that crop the photo sometimes leave the old thumbnail, so a "cropped" photo still carries the uncropped picture. Dropping the whole EXIF segment takes the thumbnail with it.

Reading EXIF, for the report and the viewer

Before removing anything, the tool shows what the photo carries, which means parsing the EXIF block itself. It is a small TIFF file: a byte-order mark ("II" for little-endian, "MM" for big), the number 42, and the offset of the first directory. Each directory is a count followed by 12-byte entries: tag, type, count, and either the value or an offset to it. Tag 0x8769 points to the camera settings directory and 0x8825 to the GPS one, whose latitude and longitude are three rationals each for degrees, minutes and seconds.

const lat = d + m / 60 + s / 3600;
return ref === 'S' ? -lat : lat; // same for longitude with 'W'

The same walker, extended to name about 80 tags, powers the EXIF viewer. It guards every offset against the block length and remembers which directories it has visited, because a malformed or hostile file can point a directory at itself.

The HEIC twist

iPhone photos are HEIC, an ISOBMFF container of nested boxes rather than segments. The EXIF block is an item listed in the meta box's iinf and located by iloc, which gives its offset and length in the file. The remover cannot rewrite HEIC losslessly yet, but reading the EXIF item out lets the HEIC to JPG converter either drop the metadata or carry it over into the JPEG's APP1 segment, with the orientation tag reset because the decoder has already rotated the pixels.

Testing it

The Playwright suite generates fixture photos with known EXIF, GPS, comments and orientation using Pillow, drops them into the page, and parses the outputs: no metadata kinds left, no GPS, a smaller file, identical scan bytes. Another check records every request the tab makes after files are added and fails if any carries a body or goes anywhere but the site itself and the anonymous usage counter. That second check is the one that matters for a privacy tool, because it is the claim users cannot see.

Code and tool

The whole thing is in src/lib/exif.ts (the stripper) and src/lib/exif-read.ts (the viewer's field reader) in the Stayput repository, MIT licensed. To try it on your own photos, open the EXIF remover, then drop the cleaned copy on the EXIF viewer to see that nothing is left.

Questions

Does removing EXIF this way reduce quality?

No. The compressed picture data is copied byte for byte and never decoded. The only exception is when you ask the tool to apply the orientation tag to a sideways photo, which has to re-encode.

Why not just draw the image on a canvas and export it?

That works and is simpler, but it re-compresses the photo, loses some quality, discards the colour profile and usually makes a phone photo larger. It also takes more memory for big images.

Is this safe to run in the browser?

Yes. The file is read with the File API into memory in your tab and the cleaned copy is handed to your browser's download. No request carries the file, which you can verify in the network tab.

Tools mentioned in this guide

More guides