June 27, 2026

Images uploaded by users should be treated as untrusted files. We focus on JPEG because, in most cases, an image uploaded by a user can simply be converted to JPEG first (if it’s not yet a JPEG). A JPEG may have been produced by a normal camera or phone, but it may also have been deliberately constructed to exploit a bug in an image parser, consume excessive memory, or carry data that has nothing to do with the picture.

Simply checking that a file has a .jpg extension is obviously not enough. Even a valid JPEG can contain unexpected metadata, embedded thumbnails, comments, and other structures. It can also be a decompression bomb: a relatively small file that expands into an enormous bitmap when decoded.

The goal is to sanitize the image so that a later consumer of the picture gets a file without those exploits. We do that on the device of the user who uploads it, before the file goes anywhere else. If the JPEG exploits a parser bug, consumes too much memory, or otherwise misbehaves, it is his application that has to deal with it.

We’ll take this case by case in the next sections: first a basic filter on the file, then a dimension check before decoding, then redrawing the picture onto a new canvas.

Basic filtering

Every JPEG begins with the bytes FF D8. If they are missing, reject the file before attempting to decode it. The same applies to size: if the payload exceeds the limit, stop there.

Swift (iOS):

private let jpegHeader: [UInt8] = [0xFF, 0xD8]

guard inputBytes.count >= jpegHeader.count,
      inputBytes.prefix(jpegHeader.count) == Data(jpegHeader) else {
    return createErrorImage(message: "Unsupported image format")
}

Kotlin (Android):

private const val DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB

fun checkJpegHeader(bytes: ByteArray): String {
    return when {
        bytes.size < 2 -> "TOO_SHORT"
        bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() -> "VALID_JPEG"
        else -> "INVALID_HEADER"
    }
}

if (checkJpegHeader(inputBytes) != "VALID_JPEG") {
    return createErrorImage("Error (invalid image)")
}
if (inputBytes.size > DEFAULT_MAX_FILE_SIZE) {
    return createErrorImage("Error (file too large)")
}

Check for a bomb before you decode pixels

A JPEG stores its dimensions in its header, so they can be checked before allocating memory for a bitmap. If either dimension is invalid or exceeds 10,000 pixels, reject the image before decoding it.

Swift (iOS):

private let maxWidth: CGFloat = 10000
private let maxHeight: CGFloat = 10000

guard let source = CGImageSourceCreateWithData(inputBytes as CFData, nil) else {
    return createErrorImage(message: "Invalid image data")
}

guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
      let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
      let height = properties[kCGImagePropertyPixelHeight] as? CGFloat,
      width > 0, height > 0,
      width <= maxWidth, height <= maxHeight else {
    return createErrorImage(message: "Image dimensions suspicious")
}

Kotlin (Android):

val boundsOptions = BitmapFactory.Options().apply {
    inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size, boundsOptions)

val width = boundsOptions.outWidth
val height = boundsOptions.outHeight

if (width <= 0 || height <= 0 || width > 10000 || height > 10000) {
    return createErrorImage("Error (invalid dimensions)")
}

Redraw, do not strip tags

We need the picture the file contains, but we do not need the file itself. The goal is to extract the pixels and create a new JPEG from them. Anything hidden in the original container is left behind.

One approach would be to parse the JPEG markers, remove APP1 (EXIF) and COM (comments), and copy everything else. The problem is that this still means handling and reproducing parts of an untrusted file. Miss a marker, overlook a thumbnail in a second IFD, or encounter a file that is valid as both a JPEG and something else, and the result may not be as clean as intended.

A safer approach is to decode the image and redraw its pixels onto a new bitmap. That bitmap is then encoded as a JPEG using the quality specified by the protocol. The resulting file is created from scratch rather than modified from the original. The original file is never passed through.

Swift (iOS):

guard let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
    return createErrorImage(message: "Invalid image data")
}

let colorSpace = CGColorSpaceCreateDeviceRGB()
let context = CGContext(
    data: nil,
    width: Int(width),
    height: Int(height),
    bitsPerComponent: 8,
    bytesPerRow: 0,
    space: colorSpace,
    bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
context?.draw(cgImage, in: CGRect(origin: .zero, size: CGSize(width: width, height: height)))

guard let outputImage = context?.makeImage() else {
    return createErrorImage(message: "Failed to create output image")
}

let mutableData = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
    mutableData as CFMutableData,
    UTType.jpeg.identifier as CFString,
    1,
    nil
) else {
    return createErrorImage(message: "Failed to create image destination")
}

CGImageDestinationAddImage(
    destination,
    outputImage,
    [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
)
guard CGImageDestinationFinalize(destination) else {
    return createErrorImage(message: "Failed to finalize image destination")
}
return mutableData as Data

Kotlin (Android):

val originalBitmap = BitmapFactory.decodeByteArray(inputBytes, 0, inputBytes.size)
    ?: return null

val cleanBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(cleanBitmap)
canvas.drawBitmap(originalBitmap, null, Rect(0, 0, width, height), null)

val outputStream = ByteArrayOutputStream()
cleanBitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)

originalBitmap.recycle()
cleanBitmap.recycle()
return outputStream.toByteArray()

What comes out is a new JPEG containing the picture and nothing carried over from the original file.

Keep reading

  1. Sanitizing PDFs Uploaded by Users

    July 11, 2026

    PDFs uploaded by users should be treated as untrusted files. A document may have come from a scanner or a word processor, but a PDF is capable of containing much more than the text …

    Continue reading

  2. Capital Hold: A Defense Against Malicious Servers

    April 16, 2026

    We usually think of a denial-of-service attack as something directed at a server. An attacker sends traffic toward a target until it can no longer cope. But a malicious server can …

    Continue reading

Entelechy, (from Greek entelecheia), in philosophy, that which realizes or makes actual what is otherwise merely potential. — Encyclopedia Britannica