Stripping EXIF and location from uploads while keeping orientation

A phone photo carries GPS coordinates and a timestamp. Read the metadata, decide what to keep, and re-encode — with the orientation flag you must not lose.

A photo taken on a phone and uploaded to your product carries the GPS coordinates where it was taken, the device model, and a timestamp accurate to the second. If your product then serves that file to other users, you are publishing your users’ home addresses. POST /v1/image/metadata on Infrai shows you what’s in there, and re-encoding through POST /v1/image/process or POST /v1/image/convert produces a file without it.

One field must survive the strip, and losing it is the classic bug: orientation.

Look at what arrived

curl -sS -X POST "https://api.infrai.cc/v1/image/metadata" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"image": "img_2fVc8nRqLmT4xBzY"}'
{
  "ok": true,
  "data": {
    "width": 4032,
    "height": 3024,
    "format": "jpeg",
    "size_bytes": 3891200,
    "exif": {
      "Make": "Apple",
      "Model": "iPhone 15 Pro",
      "DateTimeOriginal": "2026-09-14 11:02:41",
      "GPSLatitude": "51/1 27/1 2823/100",
      "GPSLongitude": "2/1 35/1 1104/100",
      "Orientation": 6
    },
    "color_space": "sRGB",
    "has_alpha": false,
    "orientation": 6
  }
}

There’s the problem in one response: latitude, longitude, device model and an exact timestamp. And there’s the field to preserve — orientation: 6 means the camera was rotated, and the pixels are stored sideways with a flag saying “display this rotated”.

Auto-orient, then strip

Strip the EXIF without applying the orientation and every portrait photo from an iPhone displays on its side. The fix is to bake the rotation into the pixels first:

curl -sS -X POST "https://api.infrai.cc/v1/image/rotate" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"image": "img_2fVc8nRqLmT4xBzY", "degrees": 0, "auto_orient": true, "format": "jpeg"}'

auto_orient: true with degrees: 0 applies whatever the EXIF flag said and produces upright pixels. After that the flag is redundant, which is exactly the state you want before discarding metadata.

Then re-encode to drop the rest:

curl -sS -X POST "https://api.infrai.cc/v1/image/convert" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"image": "img_9wQ1zV6pLkS3dHyB", "format": "webp", "store": true}'

The sanitising handler

import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})

SENSITIVE_EXIF = ("GPS", "Make", "Model", "SerialNumber", "OwnerName", "BodySerialNumber",
                  "LensSerialNumber", "DateTimeOriginal")


def metadata(image: str) -> dict:
    resp = SESSION.post(f"{API}/v1/image/metadata", json={"image": image}, timeout=60)
    resp.raise_for_status()
    return resp.json()["data"]


def sanitise(image_id: str, to_format: str = "webp") -> dict:
    """Orient first, then re-encode. Doing it the other way round loses the
    orientation flag before it has been applied, which is how a feed ends up full
    of sideways portrait photos — the most reported bug in any upload feature."""
    before = metadata(image_id)
    found = sorted({key for key in (before.get("exif") or {})
                    if any(key.startswith(prefix) for prefix in SENSITIVE_EXIF)})

    oriented = SESSION.post(
        f"{API}/v1/image/rotate",
        json={"image": image_id, "degrees": 0, "auto_orient": True, "format": "jpeg"},
        timeout=120,
    )
    oriented.raise_for_status()
    upright_id = oriented.json()["data"]["image_id"]

    converted = SESSION.post(
        f"{API}/v1/image/convert",
        json={"image": upright_id, "format": to_format, "store": True},
        timeout=120,
    )
    converted.raise_for_status()
    clean = converted.json()["data"]

    after = metadata(clean["image_id"])
    residual = sorted({key for key in (after.get("exif") or {})
                       if any(key.startswith(prefix) for prefix in SENSITIVE_EXIF)})
    return {
        "image_id": clean["image_id"], "url": clean["url"],
        "dimensions": f"{clean['width']}x{clean['height']}",
        "stripped": found, "residual": residual, "clean": not residual,
    }


if __name__ == "__main__":
    print(sanitise(os.environ["IMAGE_ID"]))

Reading the metadata back afterwards is the step that turns a claim into a check. It costs one call and it’s the difference between believing the file is clean and knowing it.

What to keep, and where

Some of that metadata is genuinely useful — you just shouldn’t publish it.

FieldPublish?Keep where
GPS coordinatesneverdiscard, or your own row if the user asked for geotagging
Device make and modelnoyour own analytics, if at all
Original timestampnoyour own record, as the capture date
Orientationirrelevant after auto-orientbaked into pixels
Colour spacekeep in the fileit affects rendering
Dimensionskeepneeded to render

If your product legitimately uses capture time or location — a travel journal, a field-inspection tool — read them from the metadata response, store them in your own database against the user’s record, and still strip them from the file you serve. The data being useful to your feature doesn’t mean it should ride along in a file anyone can download.

And if you store location against a user, that’s personal data: POST /v1/auth/consent/grant/{user_id} on the same key records the permission, and GET /v1/auth/consent/check/{user_id}/{category} gates the feature.

Do it at upload, not at serve

Sanitise on the way in. A pipeline that strips metadata when serving leaves the original sitting in your bucket, which means one misconfigured access rule publishes the untouched file — and the audit question “did we ever store users’ coordinates” has the wrong answer.

Strip, store the clean version, discard the original unless you have a reason to keep it. If you do keep originals, keep them somewhere your public paths can’t reach.

Limitations

There’s no selective EXIF editing: you can’t keep the colour profile and drop the GPS in one operation, so sanitising is a re-encode that discards metadata wholesale — which is why the auto-orient step matters and why color_space is worth checking on the output.

Re-encoding is also lossy for JPEG: the sanitised file is a generational copy. Converting to WebP or AVIF usually lands smaller anyway, so the quality cost is rarely visible, but it isn’t free.

ImageKit and imgix both handle metadata stripping at delivery time as part of their pipelines, which is more convenient if you’re already using one. What you get here is that the metadata read, the orientation, the re-encode, the consent record and the bucket the clean file lands in via PUT /v1/storage/object/put/{bucket}/{key} are one credential and one GET /v1/account/usage — so upload sanitising is one integration rather than a privacy feature spread across three vendors. Rates are live in GET /v1/discovery/image.convert (verified 2026-09-21) and drift downward as vendor contracts improve.

References

Browse more image developer guides