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
- Python 3.8 or later. The script below imports only
structandsys.exiftoolis not needed and was not used here. - curl for the upload and the download.
- Node 22 for the stand-in service, so nobody else's storage is written to.
- The Exif 2.3 standard (CIPA DC-008) for tag numbers, and the XMP specification for the second metadata block a JPEG can carry.
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
- Step 1.
Build the fixture. This writes both metadata blocks into a copy of the baseline image.
python exif.py write base.jpg fixture.jpgwrote fixture.jpg, 36091 bytes (was 35588) - Step 2.
Read the fixture back, so the tags you hunt for are known values rather than an assumption.
python exif.py read fixture.jpgfile 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.
- 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} - 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 | xxd00000000: ffd8 ffe1 0094 4578 6966 0000 4d4d 002a ......Exif..MM.* 00000010: 0000 0008 0002 0112 0003 0000 0001 0006 ................ffd8starts the JPEG,ffe1is an APP1 marker,0094is its length of 148 bytes, andExifidentifies the segment. The EXIF block reached storage. - 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 - 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.
- 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 - Step 8.
Read the stripped file the same way you read the fixture.
python exif.py read stored-strip.jpgfile 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
What to check next
- How to check file type: whether the JPEG the handler accepted is a JPEG.
- How to check sha256 of a file: the formal version of step 5.
- How to test image dimension limits on upload: the resize path decides what metadata survives.
- How to test multipart form data: the request shape these commands rely on.
- How to test file upload size limit: the other boundary this endpoint holds.
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.
Related on this site
intermediate12 minpublished updated Maks Verny