DEV Community

SEN LLC
SEN LLC

Posted on

Parsing EXIF From JPEG Bytes in the Browser — TIFF Headers, IFDs, and a GPS Privacy Warning

Parsing EXIF From JPEG Bytes in the Browser — TIFF Headers, IFDs, and a GPS Privacy Warning

EXIF is TIFF packaged inside a JPEG APP1 segment. Once you find the APP1 marker, you're reading a mini-TIFF: byte-order marker, magic number, IFD offset, then 12-byte entries in a tree of IFDs. GPS data lives in a sub-IFD reached via a pointer tag. Parsing this from scratch lets you build a fully-local viewer that warns about location metadata before the user shares their photos.

Photos from smartphones leak your home address. iPhones, Android phones, and most cameras write GPS coordinates into EXIF by default. When you post a photo to social media, many platforms strip this — but many don't, and "did my upload strip EXIF?" is not a question most users can verify.

🔗 Live demo: https://sen.ltd/portfolio/exif-viewer/
📦 GitHub: https://github.com/sen-ltd/exif-viewer

Screenshot

Features:

  • Parse EXIF from JPEG bytes (no library)
  • Camera, shooting, date, GPS, image sections
  • Red warning banner when GPS is present
  • OpenStreetMap link for coordinates
  • Strip-EXIF download via canvas re-encode
  • Fully local processing
  • Japanese / English UI
  • Zero dependencies, 49 tests

JPEG → APP1 → TIFF → IFD → tags

A JPEG file is a sequence of markers. Each marker starts with 0xFF, followed by a code byte:

  • 0xFFD8 — SOI (start of image)
  • 0xFFE0-0xFFEF — APPn (application-specific segments)
  • 0xFFE1 with "Exif\0\0" identifier — EXIF data
  • 0xFFDA — SOS (start of scan, image data follows)

Finding EXIF is just scanning for APP1 with the Exif signature:

export function findAPP1(buffer) {
  const view = new DataView(buffer);
  let offset = 2; // skip SOI
  while (offset < view.byteLength - 4) {
    const marker = view.getUint16(offset);
    if (marker === 0xFFE1) {
      const length = view.getUint16(offset + 2);
      const sig = String.fromCharCode(
        view.getUint8(offset + 4),
        view.getUint8(offset + 5),
        view.getUint8(offset + 6),
        view.getUint8(offset + 7),
      );
      if (sig === 'Exif') return { offset: offset + 10, length: length - 8 };
    }
    offset += 2 + view.getUint16(offset + 2);
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The TIFF header

APP1 payload after the Exif identifier is a TIFF file. It starts with a byte-order mark:

export function readTIFFHeader(view, offset) {
  const bo = view.getUint16(offset);
  const littleEndian = bo === 0x4949; // 'II' = Intel, 'MM' = Motorola
  const magic = view.getUint16(offset + 2, littleEndian);
  if (magic !== 0x2A) throw new Error('Invalid TIFF magic');
  const ifdOffset = view.getUint32(offset + 4, littleEndian);
  return { littleEndian, ifdOffset };
}
Enter fullscreen mode Exit fullscreen mode

Different camera manufacturers use different byte orders. Canon writes big-endian, Nikon little-endian, Apple little-endian. The parser must handle both.

IFD entries

Each Image File Directory is: a 2-byte entry count, then N × 12-byte entries, then a 4-byte pointer to the next IFD (or 0 to end).

Each entry is:

  • Tag ID (2 bytes)
  • Data type (2 bytes: BYTE=1, ASCII=2, SHORT=3, LONG=4, RATIONAL=5, etc.)
  • Count (4 bytes)
  • Value or offset (4 bytes)

If the total data size (type_size × count) fits in 4 bytes, it's stored inline. Otherwise, the 4-byte field is a pointer to another location in the TIFF.

GPS sub-IFD

The GPS data isn't in the main IFD. Instead, the main IFD has tag 0x8825 (GPSInfo) whose value is a pointer to a separate sub-IFD with GPS-specific tags:

  • 0x0001 GPSLatitudeRef ("N" or "S")
  • 0x0002 GPSLatitude (3 rationals: degrees, minutes, seconds)
  • 0x0003 GPSLongitudeRef ("E" or "W")
  • 0x0004 GPSLongitude (3 rationals)

Converting to decimal:

export function parseGPSCoordinate(degrees, minutes, seconds, ref) {
  let decimal = degrees + minutes / 60 + seconds / 3600;
  if (ref === 'S' || ref === 'W') decimal = -decimal;
  return decimal;
}
Enter fullscreen mode Exit fullscreen mode

Stripping EXIF via canvas re-encode

The easiest way to remove EXIF is to re-encode the image through a canvas. Canvas only reads pixels, not metadata:

const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0);
canvas.toBlob(blob => download(blob, 'stripped.jpg'), 'image/jpeg', 0.95);
Enter fullscreen mode Exit fullscreen mode

The result has zero EXIF — no camera info, no GPS, no dates. Quality loss from re-encoding is typically imperceptible at 0.95.

Tests

49 tests covering: APP1 discovery (including "no EXIF"), TIFF header byte-order handling, IFD entry parsing, GPS coordinate conversion (including negative refs), the tag map, and edge cases (truncated buffer, invalid magic, missing IFD).

Series

This is entry #45 in my 100+ public portfolio series.

Top comments (0)