DEV Community

Cover image for Strip EXIF Metadata Before Image Upload (Node, Python, Browser)
arsalan00
arsalan00

Posted on

Strip EXIF Metadata Before Image Upload (Node, Python, Browser)

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

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' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Verify it worked:

exiftool cleaned.jpg | grep -E "GPS|Date|Model"
# (no output)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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)