How EXIF GPS Data Is Stored in a JPEG — A Byte-Level Teardown

Written by

in

Last week I wanted to prove a point to a friend who insisted his vacation photos were “fine to post.” So I opened one of his JPEGs in a hex editor, scrolled about 40 bytes in, and read his hotel’s GPS coordinates straight off the screen — no tools, no library, just the raw bytes. That’s the thing nobody tells you about EXIF: it isn’t encrypted, hashed, or hidden. It’s sitting near the front of almost every photo your phone takes, in a format you can decode by hand once you know the layout. This post is the byte-level teardown, and at the end I’ll show why PixelStrip removes that data without touching a single pixel.

A JPEG is just a stream of markers

Every JPEG starts with two bytes: FF D8, the Start Of Image marker. After that the file is a sequence of segments, and every segment begins with FF followed by a marker byte. The one we care about is FF E1 — that’s APP1, where EXIF lives.

Here’s the front of a real photo, annotated:

FF D8              SOI (start of image)
FF E1              APP1 marker  <- EXIF starts here
00 84              segment length = 0x0084 = 132 bytes (big-endian, always)
45 78 69 66 00 00  "Exif\0\0"
49 49              "II" = Intel / little-endian byte order
2A 00              42, the TIFF magic number
08 00 00 00        offset to first IFD = 8

Two details trip people up here. First, that segment-length field is always big-endian, because it’s part of the JPEG container, not the EXIF payload. Second, the byte order flag (II for little-endian, MM for big-endian) only applies to everything after the Exif\0\0 header. From that point on, every multi-byte number flips based on those two bytes.

The TIFF header and IFD entries

What follows Exif\0\0 is a tiny TIFF file. All internal offsets are measured from the start of the byte-order mark — not the start of the file. Forget that and every pointer you read lands in the wrong place. I’ve debugged this exact off-by-six error more times than I’d like to admit.

The 4-byte offset (here 08 00 00 00 = 8) points to the first Image File Directory, or IFD0. An IFD is dead simple:

  • 2 bytes: how many entries follow
  • 12 bytes per entry
  • 4 bytes at the end: offset to the next IFD (0 means stop)

Each 12-byte entry breaks down as: a 2-byte tag ID, a 2-byte data type, a 4-byte value count, and a 4-byte field that holds either the value itself (if it fits in 4 bytes) or an offset to where the value actually lives. GPS coordinates don’t fit in 4 bytes, so they’re always stored by offset.

The tag we hunt for in IFD0 is 0x8825 — the GPS IFD pointer. Its value is an offset to a separate sub-directory holding the location tags. Jump there and you find the payload.

Decoding latitude by hand

The GPS sub-IFD uses a handful of tags. The important ones:

  • 0x0001 GPSLatitudeRef — ASCII “N” or “S”
  • 0x0002 GPSLatitude — three RATIONAL values: degrees, minutes, seconds
  • 0x0003 GPSLongitudeRef — “E” or “W”
  • 0x0004 GPSLongitude — three more RATIONALs

A RATIONAL is two 4-byte unsigned integers: a numerator followed by a denominator. So latitude is three of them — 24 bytes total. Here’s the actual block from that photo, little-endian:

25 00 00 00  01 00 00 00   ->  37 / 1   = 37 degrees
2E 00 00 00  01 00 00 00   ->  46 / 1   = 46 minutes
C4 0B 00 00  64 00 00 00   ->  3012 / 100 = 30.12 seconds

Convert degrees-minutes-seconds to decimal: 37 + 46/60 + 30.12/3600 = 37.7750° N. Pair that with the longitude block and you have a point accurate to roughly three meters. That’s precise enough to land on a specific building. My friend went quiet after I read his back.

A 40-line parser in the browser

You don’t need a library to do this. Browser DataView reads typed values out of an ArrayBuffer with explicit endianness, which is exactly what EXIF needs. Here’s the core of finding the APP1 segment and its byte order:

function findExif(view) {
  let offset = 2; // skip the FF D8 SOI
  while (offset < view.byteLength) {
    if (view.getUint8(offset) !== 0xFF) break;
    const marker = view.getUint8(offset + 1);
    const size = view.getUint16(offset + 2); // big-endian on purpose
    if (marker === 0xE1) {
      const tiff = offset + 10;            // skip marker, length, "Exif\0\0"
      const le = view.getUint16(tiff) === 0x4949; // "II"
      return { tiff, littleEndian: le, app1Start: offset, size };
    }
    offset += 2 + size; // jump to the next segment
  }
  return null;
}

Note that getUint16 defaults to big-endian, which is correct for the JPEG segment length. Once you have the littleEndian flag, you pass it to every read inside the TIFF block. Reading a RATIONAL is two reads and a divide:

function readRational(view, pos, le) {
  return view.getUint32(pos, le) / view.getUint32(pos + 4, le);
}

That’s the whole trick. Walk the IFD entries, find tag 0x8825, jump to the GPS sub-IFD, pull the latitude and longitude rationals, and apply the N/S/E/W sign. About 40 lines, no dependencies, runs offline.

Two ways to strip it — and why they differ

Now the part that actually matters. There are two ways to remove this metadata, and they are not equal.

Re-encode the whole image. Draw the photo onto a <canvas> and call toBlob(). The new file is built from raw pixels, so it carries no EXIF at all. Clean — but every pixel gets recompressed, which means slight quality loss and a completely different byte layout. That’s the approach my QuickShrink compressor takes, and I wrote up the mechanics in how browser image compression actually works. Good when you also want a smaller file.

Splice out the segment. If all you want is to delete the metadata and keep the image untouched, you cut the APP1 segment out of the byte stream and leave everything else identical:

const out = new Uint8Array(bytes.byteLength - (2 + size));
out.set(bytes.subarray(0, app1Start));
out.set(bytes.subarray(app1Start + 2 + size), app1Start);

The pixels stay bit-for-bit identical. No recompression, no quality loss, no visible change — just the location data gone. That’s what PixelStrip does.

One gotcha worth knowing: a single JPEG can carry more than one metadata block. EXIF lives in APP1, but XMP often rides in a second APP1, Photoshop data sits in APP13, and the EXIF thumbnail in IFD1 can hold its own copy of the GPS tags. A parser that removes only the first APP1 it sees will miss the rest. A real stripper loops over every APPn segment, which is the unglamorous part most “remove EXIF” snippets skip.

What to actually do with this

If you only remember one rule: platforms are inconsistent. Twitter and iMessage scrub metadata on upload; Discord, email attachments, Slack file shares, and most forums pass it through untouched. Assume the worst and clean photos before they leave your machine.

For a one-click clean that keeps your image quality intact, drop the photo into PixelStrip — it runs entirely in your browser, so the file never uploads anywhere, and it surgically removes EXIF, GPS, and XMP without recompressing. If you want the privacy reasoning rather than the byte layout, I covered that in how to strip EXIF data before sharing. The rest of the browser tools follow the same no-upload rule.

If you want to go deeper than a hex editor, file-format forensics books cover exactly this kind of byte-level metadata extraction across image, document, and filesystem formats — a solid digital forensics reference is what I keep on the shelf for the weird edge cases (full disclosure: Amazon affiliate link). It’s the difference between guessing at an offset and knowing why it’s there.


Join https://t.me/alphasignal822 for free market intelligence.

📧 Get weekly insights on security, trading, and tech. No spam, unsubscribe anytime.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Also by us: StartCaaS — AI Company OS · Hype2You — AI Tech Trends