DEV Community

monkeymore studio
monkeymore studio

Posted on

Strip Location Data From Your Photos Before Posting — Here's the Browser Tool That Does It

Every photo you take carries a hidden backpack of metadata. Camera model, lens settings, GPS coordinates, timestamps — it's all buried in the EXIF data. Most people don't know it's there. Some people really don't want it there.

I built an EXIF editor that runs entirely in your browser. Drop a JPEG, PNG, WebP, or TIFF file in, see every metadata field, edit what you want, delete what you don't, and download a clean copy. No uploads, no servers, no "we'll process it for you." Your image never leaves your device.

You can try it right now on our free online EXIF editor.

Why Keep This in the Browser?

EXIF data often contains sensitive information. GPS coordinates can reveal your home address. Timestamps can expose your daily routine. Camera serial numbers can track your gear. Sending all of that to a third-party server just to strip a few fields is absurd.

Privacy by Design

When everything runs client-side, your image bytes stay on your machine. The tool doesn't even have a backend to leak data to. It's physically impossible for us to see your photos because we never receive them.

Instant Processing

No upload queue, no processing delay. A 10 MB image gets parsed, edited, and re-encoded in milliseconds. The bottleneck is your disk read speed, not network bandwidth.

Works Offline

Load the page once and you can scrub metadata from images even without internet. Useful when you're traveling, on metered connections, or just paranoid about network traffic.

No Account, No Limits

No sign-up forms, no daily quotas, no watermarks. Process as many images as you want. The only limit is your browser's memory.

The Full Pipeline From Drop to Download

Here's what happens under the hood when you drop an image into the editor:

The entire engine is a from-scratch JavaScript implementation inspired by Perl's Image::ExifTool. Let's walk through the interesting parts.

File Type Detection: Reading the Magic

Before we can parse anything, we need to know what we're looking at. File extensions lie. Magic numbers don't.

function extractMetadata(input, options = {}) {
    let buffer;
    if (input instanceof ArrayBuffer) {
        buffer = Buffer.from(input);
    } else if (input instanceof Uint8Array) {
        buffer = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
    }

    const sig2 = buffer.toString('ascii', 0, 2);
    const sig4 = buffer.toString('ascii', 0, 4);
    const sig8 = buffer.length >= 12 ? buffer.toString('ascii', 8, 12) : '';
    const magic16 = (buffer[0] << 8) | buffer[1];
    const hex4 = buffer.slice(0, 4).toString('hex');

    if (magic16 === 0xFFD8) return extractFromJPEG(buffer, options);
    if (hex4 === '89504e47') return extractFromPNG(buffer, options);
    if (sig4 === 'RIFF' && sig8 === 'WEBP') return extractFromWebP(buffer, options);
    if (sig2 === 'II' || sig2 === 'MM') return extractFromTIFF(buffer, options);

    throw new Error(`Unsupported file format (signature: ${hex4})`);
}
Enter fullscreen mode Exit fullscreen mode
  • JPEG: starts with FF D8
  • PNG: starts with 89 50 4E 47
  • WebP: RIFF....WEBP at offset 0 and 8
  • TIFF/RAW: starts with II (little-endian) or MM (big-endian)

This covers the vast majority of images people actually use, including RAW files built on TIFF containers.

JPEG Parsing: Hunting for the APP1 Marker

JPEG files are a stream of segments, each starting with a 0xFF marker byte. EXIF data lives in the APP1 segment (0xFFE1), prefixed with the ASCII string "Exif\0\0".

function parseJPEG(buffer) {
    const reader = new ByteReader(buffer);
    const segments = [];
    let pos = 2; // Skip SOI marker

    while (pos < buffer.length - 1) {
        if (reader.get8u(pos) !== 0xFF) {
            pos++;
            continue;
        }

        let marker = 0xFF;
        while (pos < buffer.length - 1 && marker === 0xFF) {
            pos++;
            marker = reader.get8u(pos);
        }
        pos++;

        const markerCode = 0xFF00 | marker;

        // Standalone markers (no length field)
        if (marker === 0x00 || marker === 0x01 ||
            (marker >= 0xD0 && marker <= 0xD9)) {
            segments.push({ marker: markerCode, offset: pos - 2, length: 0, data: null });
            if (marker === 0xDA) break; // SOS - image data starts, stop scanning
            continue;
        }

        const length = (reader.get8u(pos) << 8) | reader.get8u(pos + 1);
        const data = buffer.slice(pos + 2, pos + length);
        segments.push({ marker: markerCode, offset: pos - 2, length, data });
        pos += length;
    }

    return { segments, buffer, size: buffer.length };
}
Enter fullscreen mode Exit fullscreen mode

The parser walks through markers until it hits SOS (0xFFDA), which signals the start of the actual compressed image stream. Everything before that is metadata. We extract the APP1 segment, strip the 6-byte "Exif\0\0" header, and hand the remaining TIFF data to the IFD parser.

The Heart of It All: TIFF IFD Parsing

Every format we support — JPEG, PNG, WebP, TIFF itself — stores EXIF data as a TIFF container. Understanding TIFF is the master key.

A TIFF file starts with an 8-byte header:

  • Bytes 0–1: Byte order (II = little-endian, MM = big-endian)
  • Bytes 2–3: Magic number (0x002A)
  • Bytes 4–7: Offset to the first Image File Directory (IFD)

Each IFD is a directory of tag entries. Think of it as a key-value store where keys are 16-bit tag IDs and values can be strings, numbers, arrays, or even pointers to other IFDs.

function parseTIFF(data, options = {}) {
    const reader = new ByteReader(data);
    const byteOrder = data.toString('ascii', 0, 2);
    reader.setByteOrder(byteOrder);

    const identifier = reader.get16u(2);
    const ifdOffset = reader.get32u(4);

    const result = { ExifByteOrder: byteOrder, tags: {}, groups: {} };
    const state = { reader, data, byteOrder, visited: new Set(), options };

    let nextOffset = processIFD(state, ifdOffset, 'IFD0', result);
    if (nextOffset && nextOffset > 0) {
        processIFD(state, nextOffset, 'IFD1', result); // thumbnail
    }

    return result;
}
Enter fullscreen mode Exit fullscreen mode

The processIFD function reads the number of entries, loops through each 12-byte tag record, and dispatches to the appropriate value reader based on the tag's data format (BYTE, ASCII, SHORT, LONG, RATIONAL, etc.).

Handling Nested IFDs

EXIF isn't flat. IFD0 can contain offsets to sub-IFDs:

  • ExifIFD (tag 0x8769): camera settings like ISO, aperture, shutter speed
  • GPSInfo (tag 0x8825): latitude, longitude, altitude
  • InteropIFD (tag 0xA005): interoperability info
  • IFD1: thumbnail image

The parser recursively follows these offsets, tracking visited locations to prevent infinite loops from malformed files.

Value Conversion

Raw TIFF values are often meaningless without context. A GPS latitude isn't a single number — it's three rationals representing degrees, minutes, and seconds. The ValueConverter.js module handles these translations:

// GPS coordinate: [deg/1, min/1, sec/100] → "40.7128° N"
function printGPSCoord(value, ref) {
    const deg = value[0][0] / value[0][1];
    const min = value[1][0] / value[1][1];
    const sec = value[2][0] / value[2][1];
    const decimal = deg + min / 60 + sec / 3600;
    return `${decimal.toFixed(4)}° ${ref}`;
}
Enter fullscreen mode Exit fullscreen mode

The Editor: Actually Modifying Metadata

Parsing is half the battle. The other half is rewriting the file with your changes applied. The ExifEditor class provides a simple API modeled after Perl's Image::ExifTool:

class ExifEditor {
    constructor() {
        this.newValues = {};
        this.deletedTags = new Set();
        this.byteOrder = 'II';
    }

    setNewValue(tagName, value) {
        this.newValues[tagName] = value;
        this.deletedTags.delete(tagName);
    }

    deleteTag(tagName) {
        this.deletedTags.add(tagName);
        delete this.newValues[tagName];
    }
}
Enter fullscreen mode Exit fullscreen mode

When you hit "Process EXIF," the editor:

  1. Reads the current metadata from the image
  2. Gathers all tags organized by IFD
  3. Removes tags in the deletion set
  4. Overwrites tags with new values
  5. Rebuilds the TIFF EXIF block from scratch
  6. Injects it back into the original file format

Rebuilding the TIFF Block

The buildTIFF function in ExifWriter.js is essentially the inverse of the parser. It takes tags grouped by IFD, resolves tag names to numeric IDs, determines the correct binary format for each value, and lays out the entire directory structure:

function buildTIFF(tagsByIFD, tagTable, byteOrder = 'II', gpsTagTable = null) {
    const isLE = byteOrder === 'II';

    // Resolve tag names to IDs and formats
    const ifdData = {};
    for (const [ifdName, tags] of Object.entries(tagsByIFD)) {
        const entries = [];
        const activeTable = (ifdName === 'GPS') ? gpsTagTable : tagTable;
        for (const [tagName, value] of Object.entries(tags)) {
            const tagID = findTagID(activeTable, tagName);
            const format = resolveFormat(value, tagInfo);
            entries.push({ tagID, name: tagName, value, format });
        }
        entries.sort((a, b) => a.tagID - b.tagID);
        ifdData[ifdName] = entries;
    }

    // ...layout calculation and binary serialization
}
Enter fullscreen mode Exit fullscreen mode

Tags are sorted by ID (TIFF spec requirement). Values larger than 4 bytes get stored in a data area after the directory entries, with the directory entry containing an offset pointer. Smaller values fit directly into the 4-byte "value/offset" field of the entry.

Format-Specific Writeback

Different image formats embed EXIF data differently, so the editor handles each one separately.

JPEG: Replace the APP1 Segment

_writeJPEG(buffer, currentMeta) {
    const exifBlock = this._buildExifBlock(currentMeta);

    // Find existing APP1 segment
    let app1Offset = -1, app1Length = 0, pos = 2;
    while (pos < buffer.length - 3) {
        if (buffer[pos] === 0xFF && buffer[pos + 1] === 0xE1) {
            app1Offset = pos;
            app1Length = 2 + ((buffer[pos + 2] << 8) | buffer[pos + 3]);
            break;
        }
        // ...skip other markers
    }

    const app1Data = Buffer.concat([Buffer.from('Exif\0\0', 'ascii'), exifBlock]);
    const newApp1Segment = Buffer.concat([
        Buffer.from([0xFF, 0xE1]),
        writeUInt16BE(app1Data.length + 2),
        app1Data
    ]);

    if (app1Offset < 0) {
        // No existing EXIF: insert after SOI
        return Buffer.concat([buffer.slice(0, 2), newApp1Segment, buffer.slice(2)]);
    }

    // Replace existing APP1
    return Buffer.concat([
        buffer.slice(0, app1Offset),
        newApp1Segment,
        buffer.slice(app1Offset + app1Length)
    ]);
}
Enter fullscreen mode Exit fullscreen mode

If you're stripping all EXIF data, the APP1 segment is simply removed. If you're adding EXIF to a clean JPEG, it gets inserted immediately after the SOI marker — the standard location.

PNG: eXIf Chunk Management

PNG stores EXIF in an eXIf chunk (or the older non-standard zxIf). The editor scans the existing chunks, replaces the EXIF chunk if present, or inserts a new one before the first IDAT chunk:

_writePNG(buffer, currentMeta) {
    const exifBlock = this._buildExifBlock(currentMeta);
    const pngInfo = parsePNG(buffer);

    let exifChunkIndex = -1;
    for (let i = 0; i < pngInfo.chunks.length; i++) {
        if (pngInfo.chunks[i].type === 'eXIf' || pngInfo.chunks[i].type === 'zxIf') {
            exifChunkIndex = i;
            break;
        }
    }

    const newExifChunk = makePngChunk('eXIf', exifBlock);
    const parts = [Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])];

    if (exifChunkIndex >= 0) {
        // Replace existing chunk
        for (let i = 0; i < pngInfo.chunks.length; i++) {
            if (i === exifChunkIndex) parts.push(newExifChunk);
            else parts.push(makePngChunk(pngInfo.chunks[i].type, pngInfo.chunks[i].data));
        }
    } else {
        // Insert before first IDAT
        let idatIndex = pngInfo.chunks.findIndex(c => c.type === 'IDAT');
        if (idatIndex < 0) idatIndex = pngInfo.chunks.length;
        for (let i = 0; i < pngInfo.chunks.length; i++) {
            if (i === idatIndex) parts.push(newExifChunk);
            parts.push(makePngChunk(pngInfo.chunks[i].type, pngInfo.chunks[i].data));
        }
    }

    return Buffer.concat(parts);
}
Enter fullscreen mode Exit fullscreen mode

Note that the PNG signature and CRC calculations are preserved. We're only touching the metadata chunks, never recompressing the actual image data.

WebP: RIFF Chunk Surgery

WebP uses RIFF chunks, and EXIF lives in an EXIF chunk. The approach is similar to PNG — rebuild the RIFF structure with the new or removed EXIF chunk:

_writeWebP(buffer, currentMeta) {
    const exifBlock = this._buildExifBlock(currentMeta);
    const webpInfo = parseWebP(buffer);

    let exifChunkIndex = -1;
    for (let i = 0; i < webpInfo.chunks.length; i++) {
        if (webpInfo.chunks[i].type === 'EXIF') {
            exifChunkIndex = i;
            break;
        }
    }

    const chunkBufs = [];
    if (exifChunkIndex >= 0) {
        for (let i = 0; i < webpInfo.chunks.length; i++) {
            if (i === exifChunkIndex) chunkBufs.push(makeRiffChunk('EXIF', exifBlock));
            else chunkBufs.push(makeRiffChunk(webpInfo.chunks[i].type, webpInfo.chunks[i].data));
        }
    } else {
        for (const chunk of webpInfo.chunks) {
            chunkBufs.push(makeRiffChunk(chunk.type, chunk.data));
        }
        chunkBufs.push(makeRiffChunk('EXIF', exifBlock));
    }

    const allData = Buffer.concat(chunkBufs);
    const fileSize = 4 + allData.length;
    const riffHeader = Buffer.concat([
        Buffer.from('RIFF'), writeUInt32LE(fileSize), Buffer.from('WEBP')
    ]);

    return Buffer.concat([riffHeader, allData]);
}
Enter fullscreen mode Exit fullscreen mode

TIFF: Direct IFD Rewrite

For TIFF files (and TIFF-based RAW files), the EXIF data is the file structure. The editor rebuilds the entire IFD structure and returns it as the new file buffer.

The React UI: Making Bytes Human-Readable

The frontend is a React client component that bridges raw binary metadata and human-friendly editing. Key design decisions:

Categorized Field Display

With over 100 possible EXIF tags, a flat list is overwhelming. Fields are grouped into categories:

const categories = [
    { key: "camera", label: "Camera", emoji: "📷" },
    { key: "datetime", label: "Date/Time", emoji: "🕐" },
    { key: "exposure", label: "Exposure", emoji: "☀️" },
    { key: "lens", label: "Lens", emoji: "🔭" },
    { key: "color", label: "Color", emoji: "🎨" },
    { key: "author", label: "Author", emoji: "👤" },
    { key: "location", label: "Location", emoji: "📍" },
];
Enter fullscreen mode Exit fullscreen mode

Each field shows a checkbox (to keep or remove), a label, the current value, and an edit button. Modified fields get a green badge. New fields get a blue badge.

Smart Value Formatting

GPS coordinates get degree symbols and hemisphere labels. Exposure times like "1/250" stay as fractions. ISO values stay plain numbers. The formatExifValue function handles the messiness:

function formatExifValue(key, value) {
    if (typeof value === 'number') {
        if (key.toLowerCase().includes('latitude') || key.toLowerCase().includes('longitude')) {
            return value.toFixed(6) + '°';
        }
        if (key.toLowerCase().includes('altitude')) {
            return value.toFixed(1) + ' m';
        }
    }
    if (value instanceof Date) return value.toLocaleString();
    if (Array.isArray(value)) return value.join(', ');
    return String(value);
}
Enter fullscreen mode Exit fullscreen mode

The Edit Workflow

The Browser Buffer Problem

One tricky detail: the core parser was originally written for Node.js, which has a native Buffer class. Browsers don't. The browser entry point fixes this by polyfilling Buffer into globalThis before loading any internal modules:

import { Buffer } from "buffer";

if (typeof globalThis !== "undefined" && !globalThis.Buffer) {
    globalThis.Buffer = Buffer;
}

import { extractMetadata } from "./src/ExifTool.js";
import { ExifEditor } from "./src/ExifEditor.js";
Enter fullscreen mode Exit fullscreen mode

This lets us reuse the same parsing and writing code across Node.js CLI tools and the browser UI without forking the logic.

Why Build Instead of Using an Existing Library?

There are existing JavaScript EXIF libraries. Most of them only read metadata. The ones that write often only support JPEG, or they require WASM binaries, or they don't handle PNG/WebP at all. Building our own gave us:

  • Full read/write support for JPEG, PNG, WebP, and TIFF
  • Pure JavaScript — no WASM, no native modules, no service workers
  • Complete control over which tags get preserved, modified, or stripped
  • Small bundle size — we only ship the tag tables we actually use

Try It Yourself

Got a photo with questionable metadata? Want to strip GPS before posting to social media? Need to batch-edit copyright info on a folder of images?

Head over to our free online EXIF editor. Upload your image, check the fields you want to keep, edit or delete the rest, and download a clean copy. Everything happens in your browser — your photos never touch our servers.

Top comments (1)

Collapse
 
notoriouslab profile image
Jacob Mei

Funny timing — I posted an article today about the opposite problem:
how exifr silently drops some iPhone 17 HEICs because its ftyp sanity
check hasn't kept up with Apple's newer brand combinations. Reading
and stripping EXIF are apparently both having a moment on dev.to today.

Nice call writing the IFD parser from scratch — that's the right move
when you want surgical editing, since most EXIF libraries are read-only
or round-trip the file with a lot of weight.

One note in case HEIC comes up later: the BMFF container is very
different from TIFF. The EXIF payload still lives in an Exif IFD, but
finding it means walking ftyp → meta → iinf/iloc to locate where the
Exif item physically sits in the file. The iloc box has a couple of
traps worth knowing about — large extent offsets that can silently cause
out-of-bounds reads, and item count fields that can trigger unexpectedly
large buffer allocations — both easy to miss if you're parsing
untrusted input. I wrote them up here:
jacobmei.com/blog/2026/0421-2yh1pu/