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: - 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. - 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); } 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'); } 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())); Or, if you'd rather not build the multipart form, there's a zero-dependency client on npm: npm install @purlo/cli 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 }, ); 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 (0)
Comments
No comments yet. Start the discussion.