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
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) -
0xFFE1with "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;
}
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 };
}
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:
-
0x0001GPSLatitudeRef ("N" or "S") -
0x0002GPSLatitude (3 rationals: degrees, minutes, seconds) -
0x0003GPSLongitudeRef ("E" or "W") -
0x0004GPSLongitude (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;
}
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);
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.
- 📦 Repo: https://github.com/sen-ltd/exif-viewer
- 🌐 Live: https://sen.ltd/portfolio/exif-viewer/
- 🏢 Company: https://sen.ltd/

Top comments (0)