Last week I uploaded a screenshot from my phone to test a feature on my own app. Out of habit I checked the saved file with exiftool. The image still had the GPS coordinates of my desk, the iPhone model, the exact second I took the screenshot, and my Apple ID's editing software fingerprint.
This wasn't a screenshot of anything sensitive. But that's the point — most user uploads aren't either, until one of them is. And if your app stores or serves the raw file, you've quietly built a privacy leak into your product.
This post walks through the three realistic places to strip EXIF metadata: on the server (Node.js with sharp), in Python (Pillow), and in the browser before the bytes ever leave the user's machine (piexifjs). I'll show working code for each, then talk about which one to actually use and why.
Skill level assumed: you've handled a file upload before. We're not covering Multer config or form parsing.
What EXIF actually contains
EXIF (Exchangeable Image File Format) is a metadata block embedded inside JPEG, TIFF, and HEIC files. PNG and WebP can carry similar metadata via different containers (tEXt, XMP). The fields that matter from a privacy standpoint:
- GPSLatitude / GPSLongitude — exact coordinates from the device GPS
- DateTimeOriginal — when the photo was taken, to the second
- Make / Model — device manufacturer and model
- Software — editing app, sometimes including version
- SerialNumber — on some cameras, a hardware serial
- OwnerName — yes, this exists, and yes, it's sometimes populated
You can see all of it with exiftool on any JPEG straight from a phone:
exiftool ~/Downloads/IMG_4291.jpg | grep -E "GPS|Date|Model|Make|Software"
Here's a real example of what came out of one of my test photos (coordinates rounded for obvious reasons):
Make : Apple
Camera Model Name : iPhone 14 Pro
Software : 17.5.1
Date/Time Original : 2026:03:12 14:22:08
GPS Latitude : 24 deg 51' 27.36" N
GPS Longitude : 67 deg 1' 53.04" E
GPS Position : 24.857600, 67.031400
Drop those last two numbers into Google Maps and you have a street address. That's the threat model.
Server-side: Node.js with sharp
The cleanest server-side approach for Node is sharp, the libvips wrapper. It strips metadata by default unless you explicitly ask it to keep some. That default alone makes it the right pick.
npm install sharp
import sharp from 'sharp';
async function stripMetadata(inputBuffer) {
// sharp drops all metadata unless .withMetadata() is called.
// We re-add orientation only, so the image renders the right way up.
const cleaned = await sharp(inputBuffer)
.rotate() // applies EXIF orientation, then discards the tag
.toBuffer();
return cleaned;
}
The .rotate() call without arguments is the trick. It reads the orientation tag, physically rotates the pixels to match, and then the output has no orientation tag to read because the rotation is now baked into the image itself. Skip this and iPhone photos taken in landscape will render sideways on Android.
Wire it into your upload handler:
import express from 'express';
import multer from 'multer';
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
app.post('/upload', upload.single('image'), async (req, res) => {
try {
const cleaned = await stripMetadata(req.file.buffer);
// store `cleaned` in S3, disk, wherever
res.json({ ok: true, size: cleaned.length });
} catch (err) {
res.status(400).json({ error: 'invalid image' });
}
});
Verify it worked:
exiftool cleaned.jpg | grep -E "GPS|Date|Model"
# (no output)
If you'd rather not pull in libvips, exif-be-gone is a tiny streaming alternative that just rewrites the JPEG/JXL container and drops the EXIF segments. Good for low-resource environments.
Python with Pillow
The Python equivalent is shorter than the Node version. Pillow's Image class lets you reload the pixels into a fresh image, which leaves the metadata behind.
from PIL import Image
import io
def strip_metadata(input_bytes: bytes) -> bytes:
"""Return image bytes with all EXIF and other metadata removed."""
img = Image.open(io.BytesIO(input_bytes))
# Handle orientation before stripping the tag
try:
exif = img._getexif() or {}
orientation = exif.get(0x0112) # EXIF Orientation tag
rotations = {3: 180, 6: 270, 8: 90}
if orientation in rotations:
img = img.rotate(rotations[orientation], expand=True)
except (AttributeError, KeyError):
pass
# Re-save without the EXIF block
out = io.BytesIO()
img_format = img.format or 'JPEG'
img.convert('RGB').save(out, format=img_format, quality=90)
return out.getvalue()
The same expand=True detail matters here. Without it, a 90° rotation on a 4032×3024 image gets cropped to fit the original frame.
In a Flask handler:
from flask import Flask, request
app = Flask(__name__)
@app.post('/upload')
def upload():
file = request.files['image']
cleaned = strip_metadata(file.read())
# store `cleaned` wherever
return {'ok': True, 'size': len(cleaned)}
For batch processing existing files on disk, exiftool -all= -overwrite_original *.jpg is faster than any Python script. Use the library when you need it inside an upload flow; use the CLI when you're cleaning a folder.
Browser-side: strip before upload
This is the option most posts skip, and it's the one that actually changes the threat model. If you strip EXIF on the server, the raw file with GPS coordinates still travels over the network, still sits in your logs, still ends up in any reverse proxy that buffers requests. Stripping in the browser means the sensitive bytes never leave the user's device.
piexifjs handles this without a build step:
<script src="https://cdn.jsdelivr.net/npm/piexifjs@1.0.6/piexif.min.js"></script>
async function stripExifInBrowser(file) {
if (file.type !== 'image/jpeg') {
// piexif only handles JPEG. For PNG/WebP, see note below.
return file;
}
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const cleaned = piexif.remove(dataUrl);
const blob = await (await fetch(cleaned)).blob();
return new File([blob], file.name, { type: 'image/jpeg' });
}
// Usage in an upload form
document.querySelector('#fileInput').addEventListener('change', async (e) => {
const original = e.target.files[0];
const cleaned = await stripExifInBrowser(original);
const formData = new FormData();
formData.append('image', cleaned);
await fetch('/upload', { method: 'POST', body: formData });
});
For PNG and WebP, the cleanest browser-only path is to re-encode through a <canvas>. Drawing the image to a canvas and exporting via canvas.toBlob() produces a file with no original metadata, because the canvas only knows about pixels.
async function reencodeViaCanvas(file) {
const img = await createImageBitmap(file);
const canvas = new OffscreenCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return await canvas.convertToBlob({ type: file.type, quality: 0.92 });
}
The canvas approach is heavier (full re-encode, slight quality loss on JPEGs) but works across formats and is bulletproof: there's literally no metadata channel from the source file to the output.
Which one should you use
Short answer: all three, in layers.
| Layer | Why it matters | Cost |
|---|---|---|
| Browser strip | Sensitive bytes never leave the device | Some CPU on the client |
| Server strip | Catches anything the browser missed or bypassed | Tiny per-upload cost |
| Periodic audit | Confirms no leak crept back in via a code change | One cron job |
In practice, server-side is non-negotiable. A user can disable JavaScript, send a curl request directly to your endpoint, or upload through a third-party client. If only the browser strips metadata, you have a privacy promise you can't actually keep. The server is the only choke point you fully control.
Browser-side stripping is the additional layer that turns "we'll clean this up after you send it" into "we never received the sensitive data in the first place." For apps where that distinction matters (medical, legal, anything involving minors, personal photo sharing), do both.
You can see this layered approach in production on services that lean hard into the privacy angle. The free image-sharing tool at ChatPic, for instance, strips EXIF automatically on upload — it's listed right next to the upload box, not buried in a settings page. That's what user-facing privacy looks like when it's actually implemented, not just marketed.
Common mistakes I've made or seen
Forgetting orientation. You strip the EXIF, then iPhone landscape photos render rotated 90° on the web. Always read the orientation tag and physically rotate before discarding.
Stripping only on the "share" action, not on upload. If your DB stores the raw file with GPS intact and you only clean on the public-share path, then your backups, your admin panel, and any internal log that thumbnails the image still leak. Strip once, at the gate.
Trusting the file extension. Users upload .jpg files that are actually PNGs, or HEIC files with a renamed extension. Detect the actual format from the magic bytes (sharp and Pillow both do this for you) before deciding which stripping strategy to apply.
Re-saving JPEGs at quality 100. This bloats the file 2-3x with no visible improvement. Quality 85-90 is the sweet spot for re-encoded JPEGs.
Assuming PNG is safe. PNG doesn't carry EXIF, but it can carry tEXt and iTXt chunks with metadata, and some camera apps and editing tools write into them. Treat PNG the same as JPEG.
Testing your implementation
Drop a real phone photo into your pipeline, then verify the output is clean:
# Should print nothing for any GPS, device, or timestamp tags
exiftool output.jpg | grep -iE "gps|model|make|software|datetime"
# Check that the file is also smaller (EXIF is usually 30-200KB)
ls -l input.jpg output.jpg
If you want a quick sanity check in CI, this Python one-liner returns non-zero if any sensitive tags remain:
python3 -c "from PIL import Image; \
import sys; exif = Image.open(sys.argv[1])._getexif() or {}; \
sys.exit(1 if any(t in exif for t in [0x0112, 0x010F, 0x0110, 0x8825]) else 0)" output.jpg
(0x8825 is the GPS sub-IFD pointer. If that's gone, the GPS block is gone.)
Wrapping up
EXIF stripping is one of those problems that looks trivial until you ship it. The libraries are easy. The judgment calls are: do it in the browser too, treat orientation as a first-class concern, strip at the gate not the share, and don't trust extensions.
If you're building anything that accepts user-uploaded images, add this to your pipeline before you add another feature. The fix is a dozen lines of code. The breach you avoid is a Twitter thread you'll be glad you never had to write.
Top comments (0)