DEV Community

Cover image for Resize and strip EXIF metadata from user uploads in Node.js
Kurt for Purlo

Posted on

Resize and strip EXIF metadata from user uploads in Node.js

If your app lets users upload images, two things are almost certainly true and
almost certainly ignored:

  1. Those photos carry EXIF metadata — including, for anything shot on a phone, the GPS coordinates of where it was taken. Serve the original file and you're broadcasting your users' home addresses.
  2. They're huge. A modern phone photo is 3–8 MB. Ship those to visitors untouched and your pages crawl and your bandwidth bill climbs.

The fix for both is the same pass: resize, re-compress, convert to a modern
format, and strip the metadata. Here's how to do it properly in Node — and where
the sharp edges are, because there are a few that bite people in production.

The DIY version with sharp

sharp (libvips under the hood) is the
right tool. The happy path is genuinely a few lines:

import sharp from 'sharp';

async function webSafe(inputPath, outputPath) {
  await sharp(inputPath)
    .resize({ width: 800, withoutEnlargement: true }) // don't upscale small images
    .webp({ quality: 80 })                            // convert + compress
    .toFile(outputPath);
}
Enter fullscreen mode Exit fullscreen mode

By default sharp drops most metadata when you re-encode, so EXIF/GPS goes
away for free here. If you ever call .withMetadata() to keep orientation, know
that you're keeping the GPS tags too — handle that deliberately.

That covers the happy path. The problem is that upload endpoints don't get the
happy path — they get whatever the internet sends them.

The three things that bite in production

1. Decompression bombs. A 100 KB PNG can decode to hundreds of megapixels
and eat all your RAM. File-size limits alone don't catch this, because the file
is tiny — it's the decoded pixel count that hurts. Cap it:

// Refuse to decode absurd images before they OOM your process.
const img = sharp(input, { limitInputPixels: 40_000_000 }); // ~40 MP
const meta = await img.metadata();
if (meta.width * meta.height > 40_000_000) {
  throw new Error('image too large');
}
Enter fullscreen mode Exit fullscreen mode

2. Stripping metadata shifts your colours. EXIF isn't the only thing in the
header — the ICC colour profile lives there too. Strip everything blindly
and wide-gamut photos come out visibly desaturated. The correct behaviour is
"drop EXIF/GPS, keep ICC," which takes a bit more care than a blanket strip.

3. Fetching images by URL is an SSRF hole. The moment you let users pass "a
URL to fetch this image from" instead of uploading, you've built a request
forwarder. Someone will point it at http://169.254.169.254/ (your cloud
metadata endpoint) or http://10.0.0.5/ (your internal network). You have to
resolve the host and reject private, loopback, link-local and metadata ranges
before you fetch
— and cap redirects, size, and time while you're at it.

None of this is exotic; it's just the stuff that never makes it into the
five-line tutorial, and it's a real afternoon (plus ongoing maintenance) to get
right on a public endpoint.

The "or don't run this yourself" option

I maintain Purlo, a small image API that does exactly this
pass — resize, convert, compress, strip — in one call, with all three
guards above baked in (pixel-count cap, ICC kept while EXIF/GPS is dropped, and
an SSRF-guarded URL fetcher). It's built for solo devs and small teams who don't
want to babysit an image pipeline. There's a free tier (1,000 images/month, no
card).

Same job as the sharp snippet, as an API call:

import { readFile, writeFile } from 'node:fs/promises';

const operations = {
  resize: { w: 800 },
  format: 'webp',
  quality: 80,
  strip: true, // removes EXIF/GPS; keeps the ICC colour profile
};

const form = new FormData();
form.append('operations', JSON.stringify(operations));
form.append('image', new Blob([await readFile('photo.jpg')]), 'photo.jpg');

const res = await fetch('https://api.purlo.dev/v1/image', {
  method: 'POST',
  headers: { Authorization: 'Bearer purlo_YOUR_KEY' },
  body: form,
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}

await writeFile('out.webp', Buffer.from(await res.arrayBuffer()));
Enter fullscreen mode Exit fullscreen mode

Or, if you'd rather not build the multipart form, there's a zero-dependency
client on npm:

npm install @purlo/cli
Enter fullscreen mode Exit fullscreen mode
import { PurloClient } from '@purlo/cli';
import { readFile } from 'node:fs/promises';

const purlo = new PurloClient({ apiKey: process.env.PURLO_API_KEY });

const { data, quotaRemaining } = await purlo.process(
  { image: await readFile('photo.jpg'), filename: 'photo.jpg' },
  { resize: { w: 800 }, format: 'webp', quality: 80, strip: true },
);
Enter fullscreen mode Exit fullscreen mode

It'll also fetch by URL for you ({ url: '...' }) — with the SSRF checks on the
server side, so you're not the one running the request forwarder.

Either way

Whether you run sharp yourself or hand it off, the checklist for an upload
endpoint is the same:

  • [ ] Resize down (and never upscale)
  • [ ] Convert to WebP/AVIF and compress
  • [ ] Strip EXIF/GPS — but keep the ICC profile
  • [ ] Cap decoded pixel count, not just file bytes
  • [ ] If you accept URLs, block private/metadata IP ranges before fetching

The sharp route is free and totally reasonable if you enjoy owning it. If you'd
rather it just be one call with the guards already there, Purlo
is there — the docs have copy-paste examples in curl,
JS, PHP, and Python.

Top comments (1)

Collapse
 
locitra profile image
Sunil Kumar Uikey

This is an important step that's easy to overlook. Image files can carry metadata that users never realize they're sharing.

For production applications, I'd also consider making the processing pipeline explicit: validate the MIME type and file signature, strip EXIF metadata, resize/re-encode the image, generate a safe filename, and avoid trusting any client-provided metadata.

One additional consideration is whether the application should preserve specific metadata intentionally—for example, copyright information—rather than automatically retaining everything.

Privacy often comes down to these small implementation details rather than a single security feature.