How to check exif data is removed from an uploaded image

Upload an image that carries GPS tags, then read the file the service serves back rather than your local copy. curl -s http://127.0.0.1:8933/files/1.jpg | head -c 32 | xxd shows ffd8 ffe1 followed by Exif when the APP1 segment survived storage, which means the coordinates are public.

Why check this

Run this on sign-off of any feature that takes an image from a user and serves it back: avatars, listing photos, support attachments. Run it again after any change to the image pipeline, because a resize library swap changes what is carried over.

The failure it prevents is specific. A user uploads a photo taken at home, the service stores the bytes as received, and the GPS IFD in that file gives the address to anyone who can fetch the avatar URL. No login is needed to read it.

What the check settles is narrow. It names the metadata segments in the file served at the URL the service gave you, and says nothing about an image CDN in front of it, the original-size derivative, or what was logged on the way in.

Prerequisites

A baseline JPEG with no metadata, so the only tags in play are the ones you put there:

curl -s https://httpbin.org/image/jpeg -o base.jpg

exif.py writes an APP1 EXIF segment and an APP1 XMP packet into a JPEG, and reads both back:

"""Write and read a JPEG APP1 EXIF segment with no image library.
Usage: python exif.py write in.jpg out.jpg | python exif.py read file.jpg"""
import struct, sys

TAGS = {0x0112: 'Orientation', 0x8825: 'GPSInfoIFDPointer', 0x0001: 'GPSLatitudeRef',
        0x0002: 'GPSLatitude', 0x0003: 'GPSLongitudeRef', 0x0004: 'GPSLongitude'}
XMP_NS = b'http://ns.adobe.com/xap/1.0/\x00'

def build_exif():
    """IFD0 with Orientation 6 and a GPS pointer; GPS IFD with 50N 30E."""
    ifd0 = struct.pack('>H', 2)
    ifd0 += struct.pack('>HHIHH', 0x0112, 3, 1, 6, 0)          # Orientation = 6
    ifd0 += struct.pack('>HHII', 0x8825, 4, 1, 38)             # GPS IFD at 38
    ifd0 += struct.pack('>I', 0)
    gps = struct.pack('>H', 4)
    gps += struct.pack('>HHI4s', 0x0001, 2, 2, b'N\x00\x00\x00')
    gps += struct.pack('>HHII', 0x0002, 5, 3, 92)              # latitude at 92
    gps += struct.pack('>HHI4s', 0x0003, 2, 2, b'E\x00\x00\x00')
    gps += struct.pack('>HHII', 0x0004, 5, 3, 116)             # longitude at 116
    gps += struct.pack('>I', 0)
    lat = struct.pack('>IIIIII', 50, 1, 27, 1, 0, 1)           # 50 deg 27' 00"
    lon = struct.pack('>IIIIII', 30, 1, 31, 1, 2400, 100)      # 30 deg 31' 24"
    tiff = b'MM\x00\x2a' + struct.pack('>I', 8) + ifd0 + gps + lat + lon
    return b'\xff\xe1' + struct.pack('>H', len(tiff) + 8) + b'Exif\x00\x00' + tiff

def build_xmp():
    body = (b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
            b'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF '
            b'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
            b'<rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/" '
            b'exif:GPSLatitude="50,27.000000N" exif:GPSLongitude="30,31.400000E"/>'
            b'</rdf:RDF></x:xmpmeta><?xpacket end="w"?>')
    seg = XMP_NS + body
    return b'\xff\xe1' + struct.pack('>H', len(seg) + 2) + seg

def segments(data):
    """Yield (marker, payload_start, payload_end) for every JPEG marker segment."""
    i = 2
    while i < len(data) - 1 and data[i] == 0xFF:
        m = data[i + 1]
        if m == 0xDA:                                          # start of scan
            break
        n = struct.unpack('>H', data[i + 2:i + 4])[0]
        yield m, i + 4, i + 2 + n
        i += 2 + n

def read_ifd(tiff, off, label):
    n = struct.unpack('>H', tiff[off:off + 2])[0]
    for k in range(n):
        e = off + 2 + k * 12
        tag, typ, cnt = struct.unpack('>HHI', tiff[e:e + 8])
        raw = tiff[e + 8:e + 12]
        if typ == 2:
            val = raw[:cnt].rstrip(b'\x00').decode('ascii', 'replace')
        elif typ == 3:
            val = struct.unpack('>H', raw[:2])[0]
        elif typ == 5:
            p = struct.unpack('>I', raw)[0]
            val = ' '.join('%d/%d' % struct.unpack('>II', tiff[p + 8 * j:p + 8 * j + 8])
                           for j in range(cnt))
        else:
            val = struct.unpack('>I', raw)[0]
        print('%-6s %-18s 0x%04x  %s' % (label, TAGS.get(tag, 'Unknown'), tag, val))
        if tag == 0x8825:
            read_ifd(tiff, val, 'GPS')

def read(path):
    data = open(path, 'rb').read()
    print('file       %s' % path)
    print('bytes      %d' % len(data))
    found = False
    for m, s, e in segments(data):
        if m == 0xE1 and data[s:s + 6] == b'Exif\x00\x00':
            found = True
            print('segment    APP1 Exif, %d bytes' % (e - s + 2))
            read_ifd(data[s + 6:e], 8, 'IFD0')
        elif m == 0xE1 and data[s:s + len(XMP_NS)] == XMP_NS:
            found = True
            print('segment    APP1 XMP, %d bytes' % (e - s + 2))
            for field in (b'exif:GPSLatitude', b'exif:GPSLongitude'):
                i = data.find(field, s, e)
                if i > -1:
                    print('XMP    %s' % data[i:data.find(b'"', i + len(field) + 2) + 1].decode())
    if not found:
        print('segment    no APP1 metadata segment')

if sys.argv[1] == 'write':
    src = open(sys.argv[2], 'rb').read()
    out = src[:2] + build_exif() + build_xmp() + src[2:]
    open(sys.argv[3], 'wb').write(out)
    print('wrote %s, %d bytes (was %d)' % (sys.argv[3], len(out), len(src)))
else:
    read(sys.argv[2])

upload-server.mjs stands in for the service under test. /upload/raw keeps the bytes, /upload/strip removes the APP1 EXIF segment first, and both serve the result at /files/<n>.jpg. Point the steps at your own endpoints instead:

// Stand-in upload service. POST /upload/raw stores the bytes as received.
// POST /upload/strip removes the APP1 Exif segment first. GET /files/<name> serves them.
import { createServer } from 'node:http';
const PORT = 8933, store = new Map();

function stripExif(buf) {                       // drop every APP1 whose payload is "Exif\0\0"
  const out = [buf.subarray(0, 2)];
  let i = 2;
  while (i < buf.length - 1 && buf[i] === 0xff && buf[i + 1] !== 0xda) {
    const len = buf.readUInt16BE(i + 2), end = i + 2 + len;
    const isExif = buf[i + 1] === 0xe1 && buf.subarray(i + 4, i + 10).toString('latin1') === 'Exif\0\0';
    if (!isExif) out.push(buf.subarray(i, end));
    i = end;
  }
  out.push(buf.subarray(i));
  return Buffer.concat(out);
}

function filePart(body) {                       // one-file multipart/form-data, no nesting
  const head = body.indexOf('\r\n\r\n') + 4;
  const tail = body.lastIndexOf('\r\n--');
  return body.subarray(head, tail);
}

createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c)).on('end', () => {
    if (req.method === 'POST' && req.url.startsWith('/upload/')) {
      let file = filePart(Buffer.concat(chunks));
      if (req.url === '/upload/strip') file = stripExif(file);
      const name = `${store.size + 1}.jpg`;
      store.set(name, file);
      res.writeHead(201, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ url: `/files/${name}`, bytes: file.length }));
    } else if (store.has(req.url.slice(7))) {
      const file = store.get(req.url.slice(7));
      res.writeHead(200, { 'content-type': 'image/jpeg', 'content-length': file.length });
      res.end(file);
    } else { res.writeHead(404).end(); }
  });
}).listen(PORT, '127.0.0.1', () => console.log(`upload service on http://127.0.0.1:${PORT}`));

Start it with node upload-server.mjs and stop it by process id afterwards.

Steps

  1. Step 1.

    Build the fixture. This writes both metadata blocks into a copy of the baseline image.

    python exif.py write base.jpg fixture.jpg
    
    wrote fixture.jpg, 36091 bytes (was 35588)
  2. Step 2.

    Read the fixture back, so the tags you hunt for are known values rather than an assumption.

    python exif.py read fixture.jpg
    
    file       fixture.jpg
    bytes      36091
    segment    APP1 Exif, 148 bytes
    IFD0   Orientation        0x0112  6
    IFD0   GPSInfoIFDPointer  0x8825  38
    GPS    GPSLatitudeRef     0x0001  N
    GPS    GPSLatitude        0x0002  50/1 27/1 0/1
    GPS    GPSLongitudeRef    0x0003  E
    GPS    GPSLongitude       0x0004  30/1 31/1 2400/100
    segment    APP1 XMP, 351 bytes
    XMP    exif:GPSLatitude="50,27.000000N"
    XMP    exif:GPSLongitude="30,31.400000E"

    The rationals read 50 degrees 27 minutes north, 30 degrees 31 minutes 24 seconds east. Orientation 6 tells a viewer to rotate the image 90 degrees clockwise.

  3. Step 3.

    Upload the fixture and keep the URL the service answers with.

    curl -s -F "file=@fixture.jpg" http://127.0.0.1:8933/upload/raw
    
    {"url":"/files/1.jpg","bytes":36091}
  4. Step 4.

    Read the first 32 bytes of what that URL serves. This is the triage read.

    curl -s http://127.0.0.1:8933/files/1.jpg | head -c 32 | xxd
    
    00000000: ffd8 ffe1 0094 4578 6966 0000 4d4d 002a  ......Exif..MM.*
    00000010: 0000 0008 0002 0112 0003 0000 0001 0006  ................

    ffd8 starts the JPEG, ffe1 is an APP1 marker, 0094 is its length of 148 bytes, and Exif identifies the segment. The EXIF block reached storage.

  5. Step 5.

    Compare the served bytes with the bytes you sent, separating "kept the metadata" from "never touched the file".

    cmp -s <(curl -s http://127.0.0.1:8933/files/1.jpg) fixture.jpg && echo "served bytes identical to the upload"
    
    served bytes identical to the upload
  6. Step 6.

    Send the same fixture to the endpoint that strips EXIF.

    curl -s -F "file=@fixture.jpg" http://127.0.0.1:8933/upload/strip
    
    {"url":"/files/2.jpg","bytes":35941}

    150 bytes fewer than the upload: the 148-byte APP1 segment plus its two marker bytes.

  7. Step 7.

    Save what the second URL serves.

    curl -s http://127.0.0.1:8933/files/2.jpg -o stored-strip.jpg -w '%{size_download} bytes saved\n'
    
    35941 bytes saved
  8. Step 8.

    Read the stripped file the same way you read the fixture.

    python exif.py read stored-strip.jpg
    
    file       stored-strip.jpg
    bytes      35941
    segment    APP1 XMP, 351 bytes
    XMP    exif:GPSLatitude="50,27.000000N"
    XMP    exif:GPSLongitude="30,31.400000E"

    The EXIF segment is gone and the coordinates are still in the file. The XMP packet carries its own copy, and the strip never looked at it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | ffd8 ffe1 then Exif in the first 32 bytes | The EXIF block reached storage | Read the GPS IFD next. Coordinates in it are a privacy defect, not a cosmetic one. | | Served bytes identical to the upload | The service writes the file through untouched | Raise it against the upload handler, not against the image renderer. There is no processing step to fix. | | No EXIF segment, XMP segment present with GPS fields | The strip is partial | The service removes one metadata container and keeps the other. Both carry coordinates. | | No APP1 segment of either kind | Nothing survived storage | Repeat the read on the original-size URL, not only on the one the page displays. | | GPS tags absent, Orientation absent | Metadata removed wholesale | Confirm the stored pixels were rotated, or the image renders turned. |

Common mistakes

Sign: The EXIF read comes back clean and the coordinates are still in the file.Cause: A JPEG can carry two metadata containers. Removing the APP1 whose payload starts with Exif leaves the APP1 XMP packet in place, and XMP has its own exif:GPSLatitude field. In the run above the stripped file still answered with 50,27.000000N.
Sign: A parser reports no metadata on a file that has some.Cause: It stopped at the first APP1 it found. After the EXIF strip the first APP1 in the file is the XMP one, which a parser that looks only at offset 2 for the string Exif reads as absent. Walk every marker segment up to the start of scan.
Sign: Metadata is gone and the image now renders on its side.Cause: Orientation 6 went out with the rest of the EXIF block. Chrome 152 reported naturalWidth 178 and naturalHeight 239 for the file that kept the tag, and 239 by 178 for the same image after the strip. Rotate the pixels when you drop the tag.
Sign: The upload returns 201 Created and the check is called passed.Cause: The status says the file was accepted, not that it was processed. The byte count in the response is the earlier signal: 36091 back from an upload of 36091 bytes means nothing was removed, while the stripping endpoint answered 35941.

What to check next

FAQ

How to check exif data of an image?

Run python exif.py read file.jpg with the script in Prerequisites. It walks the marker segments, finds the APP1 whose payload starts with Exif, and prints every tag in IFD0 and the GPS IFD.

How to check image metadata without installing anything?

For a yes or no answer, head -c 32 file.jpg | xxd is enough: an APP1 segment shows as ffe1, its length, then Exif or an XMP namespace URL. Tag values need a parser, and the one in Prerequisites is 93 lines of standard library Python.

How to check gps data in a photo?

Read tag 0x8825 in IFD0. It holds an offset to the GPS IFD, where tags 0x0001 to 0x0004 give latitude reference, latitude, longitude reference and longitude. Each coordinate is three rationals: degrees, minutes, seconds. Step 2 prints one as 50/1 27/1 0/1.

Should the service strip Orientation as well?

Only if it rotates the pixels at the same time. The measurement in Common mistakes shows the rendered dimensions swapping once the tag goes. Orientation carries no personal data, so removing it protects nobody.

Verified

Verified by Maks Vernycurl 8.21.0Python 3.13.1Node 22.23.2Chrome 152.0.0.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

The upload service ran on 127.0.0.1:8933 and was stopped afterwards. The dimensions in Common mistakes are one capture from one headless Chrome 152 on Windows 11. Step 2 was confirmed again with Pillow 11.1.0, which read the same GPS IFD as 50.0, 27.0, 0.0 north and 30.0, 31.0, 24.0 east.

intermediate12 minpublished updated Maks Verny