Extracting EXIF Metadata Server-Side with ExifTool

Run ExifTool through exiftool-vendored in Node, which keeps a pool of long-lived exiftool -stay_open processes so each read costs milliseconds rather than a Perl start-up; read only the tags you need (DateTimeOriginal, OffsetTimeOriginal, Make, Model, GPSLatitude, GPSLongitude, Orientation, ImageWidth, ImageHeight, Duration), normalise them into typed columns — a UTC timestamp, decimal coordinates, integer dimensions — and store those in your metadata table. Then produce the public derivative with metadata stripped (-all= or Sharp’s default), so location data never leaves your storage unless the user opted in.

EXIF, XMP, IPTC and QuickTime metadata tell you when and where a photo or video was taken, on what device, and how it should be rotated. That powers sorting by capture date, map views and deduplication — and it is also personal data that leaks home addresses when published. ExifTool reads more formats and tag variants than any other library, including HEIC, RAW and phone video. This page belongs to metadata indexing and search in backend validation and cloud storage architecture; the table it fills is described in how to index file metadata in PostgreSQL.

When to use this approach

  • Users upload photos or phone videos and you sort, group or map them by capture time or place.
  • You must remove location data before publishing images.
  • Your formats go beyond JPEG — HEIC, DNG, MOV, MP4 — where lighter EXIF libraries fall short.

Prerequisites

  1. Node 20+ with exiftool-vendored 28+ (bundles ExifTool for Linux, macOS and Windows; needs Perl on Linux).
  2. The upload available as a local file in a processing worker.
  3. A metadata table with typed columns for the fields you keep.
  4. A privacy decision: which fields you store, which you show, and which you strip from derivatives.

Which tags you actually need

Useful metadata tags, their sources and normalised forms Capture time comes from DateTimeOriginal plus OffsetTimeOriginal for photos or CreationDate for videos and becomes a UTC timestamp. Location comes from GPS tags and becomes decimal latitude and longitude. Device comes from Make and Model. Orientation becomes an integer. Dimensions and duration become numbers. A dozen tags cover almost every product need field tags read stored as captured at DateTimeOriginal + OffsetTimeOriginal timestamptz (UTC) video captured CreationDate, CreateDate timestamptz (UTC) location GPSLatitude, GPSLongitude (+Ref) two numeric columns device Make, Model text orientation Orientation (1–8), Rotation smallint size ImageWidth, ImageHeight, Duration integers, seconds Keep the raw tag dump only if you have a reason; it is large and full of personal data.
Read a known list of tags and convert them to typed values at the edge.

Implementation

import { ExifTool, ExifDateTime, type Tags } from "exiftool-vendored";

// One shared instance: a pool of stay_open processes. Close it on shutdown.
export const exiftool = new ExifTool({ maxProcs: 4, taskTimeoutMillis: 20_000, geolocation: false });
process.on("SIGTERM", () => exiftool.end());

export interface MediaMeta {
  capturedAt: Date | null; capturedAtLocal: string | null; tzKnown: boolean;
  lat: number | null; lon: number | null;
  make: string | null; model: string | null;
  orientation: number | null; width: number | null; height: number | null; durationSec: number | null;
}

function toDate(v: unknown): { utc: Date | null; local: string | null; tzKnown: boolean } {
  if (v instanceof ExifDateTime) {
    return { utc: v.toDate(), local: v.toISOString({ includeOffset: false }) ?? null, tzKnown: v.hasZone };
  }
  return { utc: null, local: null, tzKnown: false };
}

function clampCoord(v: unknown, max: number): number | null {
  const n = typeof v === "number" ? v : Number(v);
  return Number.isFinite(n) && Math.abs(n) <= max && n !== 0 ? Math.round(n * 1e6) / 1e6 : null;
}

export async function readMeta(path: string): Promise<MediaMeta> {
  const t: Tags = await exiftool.read(path, ["-fast"]);            // -fast: stop before trailing data in large videos
  const when = toDate(t.DateTimeOriginal ?? t.SubSecDateTimeOriginal ?? t.CreationDate ?? t.CreateDate);
  return {
    capturedAt: when.utc, capturedAtLocal: when.local, tzKnown: when.tzKnown,
    lat: clampCoord(t.GPSLatitude, 90),
    lon: clampCoord(t.GPSLongitude, 180),
    make: t.Make?.trim() || null,
    model: t.Model?.trim() || null,
    orientation: typeof t.Orientation === "number" ? t.Orientation : null,
    width: Number(t.ImageWidth ?? t.ExifImageWidth) || null,
    height: Number(t.ImageHeight ?? t.ExifImageHeight) || null,
    durationSec: typeof t.Duration === "number" ? t.Duration : null,
  };
}

/** Write a copy with every metadata block removed except colour profile and orientation. */
export async function stripForPublic(src: string, dest: string): Promise<void> {
  await exiftool.write(src, {}, ["-all=", "-tagsFromFile", "@", "-ICC_Profile", "-Orientation", "-o", dest]);
}

Storing the result:

INSERT INTO media_metadata (upload_id, captured_at, captured_local, tz_known, geo, make, model, orientation, width, height, duration_s)
VALUES ($1, $2, $3, $4,
        CASE WHEN $5::float8 IS NULL THEN NULL ELSE ST_SetSRID(ST_MakePoint($6, $5), 4326)::geography END,
        $7, $8, $9, $10, $11, $12)
ON CONFLICT (upload_id) DO UPDATE SET captured_at = EXCLUDED.captured_at, geo = EXCLUDED.geo;

Line-by-line on the decisions that matter

  • A shared, pooled instance. Starting Perl and ExifTool costs 100–300 ms. exiftool-vendored keeps processes open and sends commands over stdin, so the per-file cost drops to a few milliseconds. Create one instance per worker and end it on shutdown, or processes linger.
  • geolocation: false. Recent versions can look up city names from coordinates using a bundled database. Useful, but it adds memory and returns place names you may not want to store; enable it deliberately.
  • Date handling with offsets. DateTimeOriginal has no time zone; OffsetTimeOriginal (added in EXIF 2.31) does, and many phones write it. ExifDateTime.hasZone tells you whether the UTC value is real or assumes the server’s zone. Store both the local wall-clock time and a tz_known flag, so “photos taken in the evening” works even when UTC is unknown.
  • Rejecting 0,0 and out-of-range coordinates. Cameras without a fix sometimes write zeros; “Null Island” in the Gulf of Guinea is not where your user was. Treat it as no location.
  • -fast for videos. Without it, ExifTool may scan to the end of multi-gigabyte files to find trailing metadata. For the tags above, the header is enough.
  • Stripping with an allow-list. -all= removes everything; -tagsFromFile @ -ICC_Profile -Orientation copies back only the colour profile (so colours stay right) and orientation (so the image stays upright). Everything else — GPS, serial numbers, thumbnails with original crops — is gone.

Where extraction and stripping happen

Metadata extraction and stripping in the processing pipeline The original upload stays private. A worker reads metadata into typed database columns. The same worker creates public derivatives with metadata stripped except colour profile and orientation. Public URLs only ever point to derivatives. Read everything once, publish nothing personal original private bucket metadata worker exiftool pool metadata table typed columns, access-controlled public derivatives stripped: ICC + orientation Showing location to others becomes an explicit feature reading the table, never a side effect of the file.
Metadata goes to a table you control; files that leave your storage carry none of it.

Privacy and retention

Location and device serial numbers are personal data under GDPR and similar laws. Store coordinates only if a feature uses them, document the purpose, and delete them with the upload. Consider coarsening before storing — rounding to two decimal places (about a kilometre) is enough for “photos near Paris” and much less sensitive than a doorstep. Let users see and remove the location attached to their uploads; a map feature is welcome, a surprise one is not.

Client-side stripping, as described in client-side media preprocessing, reduces what reaches your servers, but cannot be relied on: users upload through other clients, and some browsers pass files untouched. Always strip derivatives server-side regardless.

Location precision levels and what each reveals Six decimal places locate a spot within about ten centimetres, enough to identify a house. Three decimals give about one hundred metres, a street. Two decimals give about one kilometre, a neighbourhood. One decimal gives about ten kilometres, a city area. Store the least precise location that serves the feature 6 decimals ~10 cm a doorstep 3 decimals ~100 m a street 2 decimals ~1 km a neighbourhood 1 decimal ~10 km a city area Distances are for latitude; longitude precision shrinks towards the poles.
Coarsening at write time means a data leak reveals a neighbourhood, not a home.

Running at volume

The ExifTool pool is the throughput knob. Each process handles one file at a time; maxProcs equal to the worker’s CPU count is a good start, since reads are mostly I/O and parsing. Batch-reading many files in one call is not faster than parallel single reads with the pool. For back-filling metadata across millions of existing objects, run a separate job that lists objects, downloads each to a temporary file (ExifTool needs a seekable file), reads it and deletes the copy — and use ranged downloads of the first few megabytes for JPEG and HEIC, where metadata sits at the start. Video metadata can sit at the end, so download videos fully or use -fast with a full file.

Handling files with no useful metadata

Many uploads have no capture metadata at all: screenshots, images saved from the web, files exported by editors that drop EXIF, videos re-encoded by messaging apps. Decide what “captured at” means for them before it becomes a sorting bug. A reasonable order is the metadata date, then the file’s lastModified as reported by the browser at upload (send it with the upload request, since the stored object’s timestamp is the upload time), then the upload time itself — and store which source was used, so the interface can say “uploaded on” rather than “taken on” when it only knows the former.

Do not fill gaps with guesses from the filename unless you mark them as such. IMG_20260714_183201.jpg usually does encode a capture time, but in the device’s local zone, and apps rename files freely. A captured_at_source column with values like exif, client_modified and upload keeps every downstream feature honest about how much it knows.

Configuration gotchas

Capture dates an hour or a day off. The timestamp had no offset and was interpreted in the server’s time zone. Use tzKnown to avoid showing false precision, and set the worker’s TZ=UTC so the fallback is at least consistent.

Error: spawn perl ENOENT in a slim container. exiftool-vendored on Linux runs the bundled Perl script with the system Perl. Install perl (Debian: perl-base is not enough for some modules; install perl).

Orientation applied twice. If you rotate pixels during resizing (Sharp’s .rotate()), set Orientation to 1 in the derivative instead of copying it back, or viewers rotate again.

HEIC files report width and height of a tile. Use ImageWidth/ImageHeight from the composite group that ExifTool computes rather than per-item tags; the library’s defaults already prefer the composite values.

Verification

exiftool -json -DateTimeOriginal -OffsetTimeOriginal -GPSLatitude -GPSLongitude -n -Orientation fixtures/iphone.heic
node -e 'import("./exif.js").then(async m=>{console.log(await m.readMeta("fixtures/iphone.heic"));await m.exiftool.end()})'
# After stripping: no GPS, no serial, ICC and Orientation kept.
exiftool -G1 -a -s public/iphone.jpg | grep -Ei 'gps|serial|orientation|icc_profile'

Frequently Asked Questions

Is ExifTool safe to run on untrusted files?

It is a mature parser and much less exposed than image decoders, but it still parses attacker-controlled bytes. Run it in the processing worker, with the pool’s task timeout, not in the API process.

Can I trust the capture date for anything important?

No. Metadata is written by the device and editable by anyone. Use it for sorting and display, not for evidence, billing or access decisions.

Why not use a JavaScript EXIF parser instead?

Pure JS parsers are fine for JPEG EXIF in the browser. Server-side, ExifTool’s coverage of HEIC, RAW, QuickTime and vendor maker notes saves you from a long tail of files with missing dates.