Skip to content
BDOT SOFTWAREBDOT Software

Insights / Security

Secure Image Uploads: Validate the Bytes, Strip the Metadata, Serve Safely

User images carry more than pixels: EXIF location data, embedded scripts, and spoofed formats. Validate the bytes, process in isolation, and serve from a safe origin.

Kiran Bandarupalli · 26 Oct 2026 · 5 min read

Illustration of user images passing through a secure validation checkpoint before being served by a web application

An image upload looks like the most harmless feature on a web application. A user picks a photo, the server stores it, and other users see it. But an image file is a container, and containers carry more than pixels: location data in EXIF metadata, scripts in SVG markup, and formats that are not what their extension claims. Every image a user uploads is untrusted input, and it deserves the same discipline as any other part of a secure file upload pipeline.

Images are more than pixels

Three properties make image uploads riskier than they appear. First, metadata: a phone photo can embed GPS coordinates, the device model, and timestamps in EXIF data, and that data travels with the file unless something removes it. Serving the original file can quietly publish a user's location. Second, active content: SVG is XML, and an SVG file can contain JavaScript that executes when the image is viewed in a browser context, which turns an upload into a stored cross-site scripting vector. Third, format confusion: browsers and image libraries sniff content, so a file named photo.jpg may actually be HTML, and a file that renders fine in one decoder may exploit a parser bug in another.

The fix is not to reject image uploads or to trust a single check. It is to treat the upload as a pipeline with a boundary at each stage: validate the bytes, process the image in isolation, and serve only the derivatives you produced yourself.

Validate the bytes, not the extension

The filename extension and the browser-supplied content type are both attacker-controlled hints. Validate the actual bytes instead. Read the file signature, the magic bytes at the start of the file, and accept only the formats your product genuinely needs. JPEG starts with FF D8 FF, PNG with 89 50 4E 47, WebP with RIFF and WEBP, and GIF with GIF87a or GIF89a. Reject anything else before the file reaches a decoder, and enforce a size limit at the proxy as well as in the application so a single upload cannot exhaust memory or disk.

A small server-side check makes the intent explicit:

const SIGNATURES = {
  jpeg: [0xff, 0xd8, 0xff],
  png: [0x89, 0x50, 0x4e, 0x47],
  webp: [0x52, 0x49, 0x46, 0x46],
};

function detectImageType(buffer) {
  for (const [type, sig] of Object.entries(SIGNATURES)) {
    if (sig.every((byte, i) => buffer[i] === byte)) return type;
  }
  return null;
}

const type = detectImageType(header);
if (!type || !ALLOWED_TYPES.includes(type)) {
  throw new Error('Unsupported image format');
}

Magic bytes are a first gate, not a full verdict. A file can carry a valid signature and still be malformed, so the next step is to decode it in a constrained worker and let the decoder confirm the format. If decoding fails, reject the upload. If it succeeds, you have a verified image, and you no longer need the original bytes at all.

Process in isolation, then strip what you do not need

Image decoding and conversion belong in a worker with time, memory, and filesystem limits, separate from the request handler. Decoders have a long history of parser vulnerabilities, and a malicious image should be able to kill a worker, not the application. Run that work as an observable asynchronous workflow with a clear state for each image: received, processing, ready, or rejected.

Re-encode every accepted image rather than storing the upload as received. Re-encoding through a trusted library discards malformed structures, embedded scripts, and appended data hiding after the image payload. While you re-encode, strip EXIF and other metadata by default, and generate the derivatives the product actually displays: a thumbnail, a display size, and nothing else. Keeping the original around for convenience preserves exactly the metadata and attack surface you just worked to remove. If a workflow genuinely needs the original, store it privately, never serve it, and record why.

SVG deserves a special rule. If the product must accept SVG, treat it as untrusted markup: sanitize it with a dedicated SVG sanitizer, strip scripts, event handlers, and external references, and serve it with a content type and origin that cannot execute it in the application's context. If the product does not need SVG, reject it outright. Most applications do not.

Serve images from a safe origin

Serving user images from the same origin as the application lets any active content in an image run with the application's privileges. Serve user images from a separate, cookie-less domain instead, so a script that somehow executes there has no session to steal. Set an explicit Content-Type for each derivative, add X-Content-Type-Options: nosniff so browsers do not reinterpret the bytes, and use Content-Disposition carefully for downloads. For private images, authorize the request and issue signed, expiring download URLs rather than exposing a predictable path.

Cache headers matter too. A derivative is immutable once generated, so it can carry a long cache lifetime, while the authorization decision for a private image must stay short-lived. Keep those two lifetimes separate: cache the bytes, never the permission.

Image-upload security checklist

  • Accept only the image formats the product needs, verified by magic bytes, not extension or MIME header.
  • Enforce size limits at the proxy and in the application, and reject oversized uploads before decoding.
  • Decode and re-encode in a constrained worker; discard the original bytes unless there is a documented reason to keep them privately.
  • Strip EXIF and metadata by default, including GPS location, device details, and timestamps.
  • Sanitize SVG with a dedicated sanitizer or reject it; never serve raw user-supplied SVG from the application origin.
  • Serve derivatives from a separate, cookie-less domain with explicit Content-Type and nosniff headers.
  • Authorize private images per request and serve them through signed, expiring URLs.
  • Log an opaque image ID and processing outcome, never the image contents or user-supplied filenames.

This checklist pairs with the broader go-live checklist for business applications: the same boundaries, applied to the specific risks images carry. For teams building custom software or web applications that handle user media, these checks are a small, fixed cost compared with the cost of discovering a location leak or a stored script after launch.

Secure image handling is not a feature to bolt on after launch. Build the pipeline once, validate at every boundary, and the upload button stays what users expect it to be: a simple way to share a picture. If your team is planning a media feature and wants the pipeline reviewed, talk to us.