In a Node.js Express upload pipeline, strip EXIF location data before publishing a logistics product image, then verify the re-encoded bytes. Use on-demand processing only when the original must serve several controlled derivatives. The decisive point is where an unverified image can be prevented from crossing the publication boundary.
TL;DR: Decode each accepted upload, normalize its orientation, re-encode pixels into a new image, and run a second metadata parser against those exact output bytes. Store or publish only after both gates pass.
| Decision | Pick it when | Main consequence |
|---|---|---|
| Process at upload | One privacy policy covers every public derivative | More work before acceptance; simpler reads |
| Process on demand | Private originals feed distinct controlled derivatives | Every cache miss becomes an enforcement point |
| Hybrid | A private original is required, but delivery must stay predictable | Storage permissions and lifecycle rules must remain separate |
The examples use TypeScript, Express, Sharp for decoding and encoding, and Exifr for independent inspection. Those libraries are replaceable. The two-gate contract is the useful part.
How should Node.js strip EXIF location data before publishing?
Pick upload-time processing when warehouse staff photograph labels, cartons, or products and the public catalog needs one normalized asset. Phones can attach location-related metadata to image files; the browser upload boundary does not remove it. Re-encoding once means the catalog, thumbnail jobs, and CDN begin with the same approved bytes.
Pick on-demand processing when a private master must generate outputs with materially different rules. A carrier claim workflow might retain evidence under restricted access while the marketplace view exposes only a sanitized derivative. This can work, but the cache key must identify the transformation policy as well as the source.
The hybrid design is often the honest choice. Draw it in words: uploader to quarantine, quarantine to decoder, decoder to encoder, encoder to metadata verifier, verifier to public object storage. There is no arrow from quarantine to the public bucket.
No exceptions.
My default for this job is upload-time sanitization. Background removal already creates a new visual asset, so privacy validation belongs in that same pre-publication transaction rather than on every delivery.
Implement the two gates before publication
Start by limiting what reaches the decoder. OWASP recommends allow-listing extensions, validating file type rather than trusting the Content-Type header, generating filenames, setting size limits, and storing uploads outside the webroot. Decoding is not a substitute for those controls.
This handler checks signatures, reads orientation during decode, and emits a fresh PNG. PNG suits a background-removal result that needs transparency. MDN documents PNG transparency and browser image-format trade-offs.
The limits in the example are policy inputs, not universal constants: one file, 12 MiB, and 40 million input pixels. A team should set them from its actual camera fleet, memory budget, and decoder behavior. The trade-off is explicit. A larger pixel ceiling accepts more high-resolution warehouse photos, but it also gives each decode more room to consume memory and CPU; a smaller ceiling rejects legitimate uploads earlier. Test the chosen boundary with a photo just below it, a photo just above it, and a compressed image whose byte size looks harmless while its decoded dimensions do not. This is also why checking only Multer's byte limit leaves a gap. The request can satisfy that first limit and still be unsuitable for the decoder. Signature validation, byte limits, pixel limits, decoding, encoding, and output inspection are separate decisions, and each one should fail closed before public storage receives a key.
import express, { type Request, type Response } from "express";
import multer from "multer";
import sharp from "sharp";
import * as exifr from "exifr";
import { randomUUID } from "node:crypto";
const app = express();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 12 * 1024 * 1024, files: 1 },
});
function acceptedSignature(input: Buffer): boolean {
const jpeg = input.length >= 3 &&
input[0] === 0xff && input[1] === 0xd8 && input[2] === 0xff;
const png = input.length >= 8 && input.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
return jpeg || png;
}
const forbiddenLocationKeys = new Set([
"GPSLatitude", "GPSLongitude", "GPSLatitudeRef", "GPSLongitudeRef",
"GPSAltitude", "GPSAltitudeRef", "GPSPosition",
]);
async function assertNoLocationMetadata(output: Buffer): Promise<void> {
const metadata = await exifr.parse(output, {
tiff: true, exif: true, gps: true, xmp: true, iptc: true, icc: false,
});
const leaked = Object.keys(metadata ?? {}).filter((key) =>
forbiddenLocationKeys.has(key),
);
const gps = await exifr.gps(output);
if (leaked.length > 0 || gps !== undefined) {
throw new Error(`location metadata remains: ${leaked.join(",") || "GPS"}`);
}
}
async function sanitizeProductPhoto(input: Buffer) {
if (!acceptedSignature(input)) throw new Error("unsupported image signature");
const decoded = sharp(input, { failOn: "error", limitInputPixels: 40_000_000 });
const info = await decoded.metadata();
if (!info.width || !info.height) throw new Error("image dimensions unavailable");
// Replace this pixel operation with the background-removal result.
const bytes = await decoded.rotate().ensureAlpha()
.png({ compressionLevel: 9 }).toBuffer();
await assertNoLocationMetadata(bytes);
return { key: `catalog/${randomUUID()}.png`, bytes, contentType: "image/png" };
}
app.post("/catalog/photos", upload.single("photo"), async (req: Request, res: Response) => {
if (!req.file) {
res.status(400).json({ error: "photo is required" });
return;
}
try {
const photo = await sanitizeProductPhoto(req.file.buffer);
// Persist photo.bytes only after verification succeeds.
res.status(201).json({ key: photo.key, contentType: photo.contentType });
} catch {
res.status(422).json({ error: "image could not be accepted" });
}
});
Calling rotate() without an angle uses EXIF orientation, if present, and removes the orientation tag according to Sharp's documentation. This avoids the sideways-photo trap: deleting the tag before applying its meaning changes display orientation. Sharp removes metadata by default unless retention methods are called, but encoder behavior is only gate one.
Gate two parses bytes, not the upload and not an object assembled from the first parser. Notice what the response omits: metadata values. Location data should not migrate into logs.
Check the output.
How do you prove the re-encoded file is clean?
A test that checks only GPSLatitude is too narrow. Build fixtures for an oriented JPEG carrying GPS fields, a normal JPEG, a transparent PNG, a truncated image, and bytes whose declared MIME type disagrees with their signature. The assertion target is always the stored candidate.
Use one parser in the request path and a second audit tool in CI or a scheduled sample. ExifTool supports JSON output and grouped metadata extraction. Independent implementations reduce the chance that one parser's blind spot becomes the definition of success.
import { describe, expect, it } from "vitest";
import sharp from "sharp";
import * as exifr from "exifr";
import { readFile } from "node:fs/promises";
describe("product photo publication boundary", () => {
it("applies orientation and emits no readable GPS data", async () => {
const input = await readFile("fixtures/oriented-with-gps.jpg");
const result = await sanitizeProductPhoto(input);
const outputInfo = await sharp(result.bytes).metadata();
expect(outputInfo.format).toBe("png");
expect(outputInfo.hasAlpha).toBe(true);
expect(await exifr.gps(result.bytes)).toBeUndefined();
});
it("rejects a non-image signature", async () => {
await expect(sanitizeProductPhoto(Buffer.from("not an image")))
.rejects.toThrow("unsupported image signature");
});
});
For CI, invoke ExifTool against generated fixtures. Do not make that subprocess the only online check; publication remains contingent on the in-process verifier.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
async function auditFixture(path: string): Promise<void> {
const { stdout } = await execFileAsync("exiftool", ["-json", "-gps:all", path]);
const [report] = JSON.parse(stdout) as Array<Record<string, unknown>>;
const gpsKeys = Object.keys(report).filter((key) => key.startsWith("GPS"));
if (gpsKeys.length > 0) {
throw new Error(`fixture contains GPS metadata: ${gpsKeys.join(",")}`);
}
}
That creates a crisp before/after: the input fixture contains location tags; the output has the expected pixel properties; two readers find no GPS values. Stronger than hope.
Observe the policy, not private data
Instrument four low-cardinality outcomes: accepted, unsupported signature, decode failure, and post-encode verification failure. Add duration and output-byte histograms. Avoid user filenames, coordinates, raw metadata, and unbounded exception text as metric labels.
An alert should describe the broken invariant. Page when verification_failure is nonzero over a meaningful publication window, because no such file should advance. A rise in decode failures is different: it may mean damaged uploads or a newly common format. Keep those signals separate.
Track queue age if background removal is asynchronous. Upload-time enforcement does not require holding an HTTP connection open; it requires quarantining the object until processing and verification finish. Publishability is a state transition, not a timestamp.
Metadata removal does not remove location clues visible in pixels. Shipping labels, street signs, faces, and reflections need a separate content policy and, where appropriate, redaction. Filenames and catalog fields can leak information too.
This implementation accepts JPEG and PNG and emits PNG. Expanding the allow-list requires decoder support, signature tests, resource limits, and fresh fixtures. Animated inputs need an explicit frame policy. Keep private originals out of the public path, verify the exact bytes you publish, and make a failed second gate stop the transition.
References
- https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
- https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- https://sharp.pixelplumbing.com/api-operation/#rotate
- https://sharp.pixelplumbing.com/api-output/#withmetadata
- https://github.com/MikeKovarik/exifr
- https://exiftool.org/exiftool_pod.html













