Image Processing: GD, Thumbnails, EXIF, and Upload Safety

Use PHP's GD extension to create, resize, crop, and re-encode images; read and strip EXIF metadata; understand ImageMagick/Imagick as an alternative; and validate uploaded images safely against both malicious payloads and decompression-bomb resource exhaustion.

Any site that lets users upload avatars, product photos, or attachments eventually has to answer three questions: how do I generate a thumbnail without loading a 40-megapixel photo into memory and crashing the server, how do I make sure the 'image' someone uploaded is actually an image and not a PHP shell with a .jpg extension, and how do I strip the GPS coordinates and camera serial number a phone silently embeds in every photo before it goes public. PHP answers all three with the GD extension (bundled with virtually every PHP install) plus a handful of metadata and validation functions — and the order you call them in matters as much as which functions you call.

GD: loading, creating, and saving images

GD works with an in-memory image resource (an instance of the GdImage class since PHP 8.0, previously a resource). You create one either by loading an existing file with a format-specific function — imagecreatefromjpeg(), imagecreatefrompng(), imagecreatefromgif(), imagecreatefromwebp() — or from scratch with imagecreatetruecolor($width, $height) for a blank RGB canvas. Every GD function that draws, resizes, or reads pixels operates on this GdImage handle, and you must explicitly free it (or let it go out of scope) and explicitly save it back out with imagejpeg(), imagepng(), imagewebp(), or imagegif() — nothing is written to disk until you call one of those.

The single most important GD function for practical work is imagecopyresampled() (or the faster but lower-quality imagecopyresized()), which copies a rectangular region from a source image into a destination image while resampling pixels — this is how both resizing and cropping actually happen: you allocate a destination canvas of the target size, then copy from a chosen rectangle of the source into all (or part) of the destination. There is no single 'resize()' or 'crop()' function in core GD; both operations are this same copy-with-a-rectangle primitive used differently.

Resizing, cropping, and thumbnails

A proportional resize (fit within a bounding box, preserve aspect ratio) computes a scale factor from the smaller of width-ratio and height-ratio, then copies the *entire* source rectangle into a *smaller* destination canvas. A crop-to-fill thumbnail (exact target dimensions, no distortion) does the opposite: it computes a scale factor from the *larger* ratio so the image more than fills the target box, then copies only a centered sub-rectangle of the source — sacrificing the edges — into a destination canvas of the exact requested size. Both use the same imagecopyresampled() call; the difference is entirely in which rectangle you compute for the source and destination arguments.

Compression/quality settings differ per format and are not interchangeable: imagejpeg($img, $path, $quality) takes an integer 0–100 (lossy, higher is bigger and cleaner — 75–85 is the usual production sweet spot), imagepng($img, $path, $level) takes compression *level* 0–9 (lossless either way — the level only trades encode time for file size, not visual quality), and imagewebp($img, $path, $quality) takes 0–100 like JPEG but with generally better compression at equivalent visual quality, which is why WebP is the common default for generated thumbnails today.

Metadata: EXIF, and why you almost always want to strip it

JPEG files from cameras and phones carry EXIF metadata — camera make/model, exposure settings, timestamp, and often GPS latitude/longitude if location services were on. PHP's exif_read_data($filename) (part of the ext-exif extension) returns this as an associative array, which is genuinely useful for a photography site that wants to display 'shot at f/2.8, ISO 400'. But for user-uploaded content on a general site, EXIF GPS data is a real privacy leak: a user uploading a photo from their home can unknowingly publish their home's coordinates to anyone who inspects the file.

The practical fix is not to hand-edit the EXIF block — it's to re-encode the image. When GD loads an image with imagecreatefromjpeg() and saves it back with imagejpeg(), it rebuilds the JPEG from raw decoded pixel data and does not copy EXIF metadata into the new file by default, so a load-then-save round-trip through GD is itself the metadata-stripping step. This is one more reason (beyond security, covered next) that re-encoding every uploaded image is the standard practice rather than storing the uploaded bytes verbatim.

ImageMagick / Imagick as an alternative

The Imagick extension is a PHP binding to the ImageMagick library, and it's the other major option alongside GD. Where GD is bundled everywhere and fast for simple bitmap operations, Imagick supports a much wider format range (including PDF page rasterization, animated GIF/WebP composition, and many scientific/print formats like TIFF and PSD), has richer color-space and compositing controls, and its API is more consistently object-oriented ($img = new Imagick($path); $img->resizeImage(...)). The tradeoff is that it's a separate system library dependency (not bundled with PHP core the way GD's basic build is) and historically has had more CVEs tied to obscure format decoders — so if you only need JPEG/PNG/WebP thumbnailing, GD is usually the simpler, lower-attack-surface choice; reach for Imagick when you need format coverage or compositing GD doesn't offer.

Upload validation: don't trust the extension or the MIME header

A file named photo.jpg with a Content-Type: image/jpeg header from the browser is not evidence of anything — both are client-supplied strings an attacker fully controls. The extension is just a filename suffix, and the Content-Type in $_FILES[...]['type'] is whatever the uploading browser or script claimed, unchecked by PHP. The classic attack is a 'GIFAR'-style or polyglot file: valid image bytes at the front (so an image parser is happy) with a PHP payload appended or embedded in a comment/metadata field, uploaded with a .jpg name, then included or executed via some secondary vulnerability (e.g. a misconfigured upload directory that still executes .php files, or a local-file-inclusion bug).

The two real defenses, used together: first, verify actual image content, not the claimed type — call getimagesize($path) (or exif_imagetype()) on the uploaded file and check that it returns valid, plausible image data; a text/PHP payload with a fake extension will fail this because there's no real image structure to parse. Second, and more importantly, re-encode the image through GD (load with imagecreatefromjpeg()/imagecreatefrompng(), save with imagejpeg()/imagepng()) rather than storing the uploaded bytes as-is — GD only writes out the pixel data it decoded, so any appended or embedded payload bytes that aren't valid image structure simply never make it into the output file. Checking the extension is not validation; re-encoding is what actually neutralizes a crafted payload.

• Never trust $_FILES[...]['type'] or the filename extension as your only check. • getimagesize() returns false (or throws in strict contexts) for non-image data — treat that as an immediate reject. • Re-encoding through GD/Imagick strips both malicious embedded payloads and privacy-leaking EXIF metadata in one step. • Store re-encoded files under a generated name (never the original filename) in a directory that does not execute PHP.

Image bombs and resource exhaustion

An 'image bomb' (decompression bomb applied to images) is a tiny file — sometimes just a few kilobytes — that decodes into an enormous bitmap: a PNG can legally declare dimensions like 40000×40000 pixels while the compressed file itself is small, because flat-color or highly repetitive regions compress extremely well. If you call imagecreatefrompng() directly on such a file without checking dimensions first, GD will attempt to allocate the full decoded bitmap in memory — 40000×40000×4 bytes (RGBA) is roughly 6.4 GB — and either exhaust the server's memory or hit PHP's memory_limit and fatally error, potentially taking down the worker process handling other requests too.

The guard is cheap and should run before any full decode: call getimagesize($path) first, which reads only the file's header to report width and height without decoding the full pixel data, and reject the upload if width × height (or width or height individually) exceeds a sane application-defined ceiling — e.g. reject anything over 8000px on a side, or over ~25 megapixels total, well before calling imagecreatefromjpeg()/imagecreatefrompng() on it. This single header check is the difference between an attacker's 5KB file safely bouncing with a 'file too large' message and that same 5KB file taking down a worker process.

Core GD operations

GD functions return a GdImage object (PHP 8.0+); imagedestroy() is still recommended in loops/batch scripts to release memory promptly rather than waiting for GC. getimagesize() should always run before any imagecreatefrom*() call on untrusted input.

// Load
$src = imagecreatefromjpeg($path);

// Create a blank canvas
$dst = imagecreatetruecolor($width, $height);

// Resize / crop primitive
imagecopyresampled(
    $dst, $src,
    $dstX, $dstY, $srcX, $srcY,
    $dstW, $dstH, $srcW, $srcH
);

// Save
imagejpeg($dst, $outPath, quality: 82);
imagewebp($dst, $outPath, quality: 82);
imagepng($dst, $outPath, quality: 6); // 0-9 compression level, lossless

// Free memory explicitly for large batch jobs
imagedestroy($src);
imagedestroy($dst);

Code Examples

The dimension check runs against getimagesize()'s header-only read, before any decoder touches the file, so a crafted tiny file that claims huge dimensions is rejected cheaply. Only after that guard passes does the code call a real decoder, and it immediately re-encodes rather than copying the original bytes to storage. getimagesize() reads only enough of the file to parse its header/dimensions block — it does not allocate a full decoded bitmap, which is exactly why it's safe to call on untrusted input before the expensive decode step.

if ($width > $maxSide || $height > $maxSide || ($width * $height) > $maxPixels) {

Uses max() of the two scale ratios (not min()) so the resized image is guaranteed to be at least as large as the target box in both dimensions, then a centered imagecopy() takes an exact-size crop — the standard 'fill and crop' thumbnail pattern used by avatar and gallery-grid uploaders. Expected result: A WebP file at exactly $targetW x $targetH pixels with no distortion, edges cropped evenly from the longer axis.

<?php

declare(strict_types=1);

final class ImageUploadRejected extends \RuntimeException {}

function validateAndReencodeUpload(string $tmpPath, string $destPath): void
{
    // 1. Cheap header-only check FIRST — never fully decode before this.
    $info = @getimagesize($tmpPath);
    if ($info === false) {
        throw new ImageUploadRejected('File is not a valid image.');
    }

    [$width, $height, $type] = $info;

    // 2. Guard against decompression-bomb style files: reject absurd dimensions
    //    BEFORE calling any imagecreatefrom*() decoder.
    $maxSide = 8000;
    $maxPixels = 25_000_000; // ~25 megapixels
    if ($width > $maxSide || $height > $maxSide || ($width * $height) > $maxPixels) {
        throw new ImageUploadRejected('Image dimensions exceed the allowed limit.');
    }

    // 3. Only allow known-safe types, and load with the matching decoder.
    $src = match ($type) {
        IMAGETYPE_JPEG => imagecreatefromjpeg($tmpPath),
        IMAGETYPE_PNG  => imagecreatefrompng($tmpPath),
        IMAGETYPE_WEBP => imagecreatefromwebp($tmpPath),
        default => throw new ImageUploadRejected('Unsupported image type.'),
    };
    if ($src === false) {
        
…
<?php

declare(strict_types=1);

function makeThumbnail(string $srcPath, string $destPath, int $targetW, int $targetH): void
{
    $src = imagecreatefromjpeg($srcPath);
    $srcW = imagesx($src);
    $srcH = imagesy($src);

    // Scale so the source MORE than fills the target box, then crop the excess.
    $scale = max($targetW / $srcW, $targetH / $srcH);
    $scaledW = (int) ceil($srcW * $scale);
    $scaledH = (int) ceil($srcH * $scale);

    $resized = imagecreatetruecolor($scaledW, $scaledH);
    imagecopyresampled($resized, $src, 0, 0, 0, 0, $scaledW, $scaledH, $srcW, $srcH);

    // Crop a centered targetW x targetH window out of the resized image.
    $cropX = (int) (($scaledW - $targetW) / 2);
    $cropY = (int) (($scaledH - $targetH) / 2);

    $thumb = imagecreatetruecolor($targetW, $targetH);
    imagecopy($thumb, $resized, 0, 0, $cropX, $cropY, $targetW, $targetH);

    imagewebp($thumb, $destPath, 82);

    imagedestroy($src);
    imagedestroy($resized);
    imagedestroy($thumb);
}

Upload-to-thumbnail pipeline with the resource-exhaustion guard

The dimension guard sits before the only line in the whole pipeline that can allocate a huge bitmap.

Uploaded file
       |
       v
  getimagesize()  --- header-only read, no full decode
       |
       v
  dimensions sane? --No--> reject (400)
       |
      Yes
       |
       v
  imagecreatefromjpeg/png/webp()   <- full decode happens ONLY here
       |
       v
  imagecopyresampled()  -> resize / crop
       |
       v
  imagejpeg/webp/png()  -> re-encode (strips EXIF + any injected bytes)
       |
       v
  store under generated filename, non-executable directory

Why every serious upload pipeline re-encodes

Production systems (image CDNs, social platforms, e-commerce catalogs) essentially never store the raw bytes a user uploaded. The standard pipeline is: header-only dimension check, decode through a trusted library, re-encode to one or more sizes/formats, discard the original. This single practice defeats extension-spoofing attacks, decompression bombs, and metadata leaks simultaneously, which is why it's treated as non-negotiable rather than an optional hardening step.

if (str_ends_with($_FILES['avatar']['name'], '.jpg')) { move_uploaded_file($_FILES['avatar']['tmp_name'], $dest); }

$info = getimagesize($tmp); if ($info === false || $info[0] * $info[1] > $maxPixels) { reject(); } $img = imagecreatefromjpeg($tmp); imagejpeg($img, $dest, 85);

Checking the filename extension validates nothing an attacker controls; getimagesize() plus a dimension ceiling plus a decode-and-re-encode round trip is what actually rejects non-images, blocks resource-exhaustion payloads, and strips both malicious bytes and privacy-leaking EXIF data.

Why should getimagesize() be called before imagecreatefromjpeg() on an untrusted upload? A. getimagesize() is required by PHP before any decoder function can run B. getimagesize() reads only header dimensions without fully decoding the image, so it can cheaply reject files whose declared dimensions would exhaust memory on full decode C. getimagesize() automatically strips EXIF metadata D. imagecreatefromjpeg() cannot read files that getimagesize() hasn't already opened

B. getimagesize() reads only header dimensions without fully decoding the image, so it can cheaply reject files whose declared dimensions would exhaust memory on full decode — getimagesize() parses only the header to report dimensions, which lets you reject a decompression-bomb-style file (huge declared dimensions, tiny compressed size) before any function allocates memory for the full decoded bitmap.

What is the most reliable way to strip malicious payload bytes appended to a crafted image upload? A. Check that the filename ends in .jpg, .png, or .webp B. Check the Content-Type header sent by the browser C. Decode the image with a real decoder (GD/Imagick) and re-encode it, so only valid decoded pixel data is written to the stored file D. Scan the file for the string <?php

C. Decode the image with a real decoder (GD/Imagick) and re-encode it, so only valid decoded pixel data is written to the stored file — Re-encoding from decoded pixel data only ever writes out what the decoder actually parsed as image content — non-image bytes appended or embedded by an attacker are never part of that decoded pixel data, so they cannot survive the round trip.

Write a function safeThumbnail(string $tmpUploadPath, string $destPath): void that: (1) rejects the file if getimagesize() fails or reports more than 20 megapixels, (2) loads it with the correct imagecreatefrom*() based on detected type, (3) produces a 300x300 crop-to-fill thumbnail, (4) saves it as WebP at quality 80, (5) never writes the original uploaded bytes anywhere on disk. Hint: Combine the dimension-guard example with the crop-to-fill thumbnail example — the guard runs first, then its decoded $src feeds directly into the resize/crop logic instead of re-reading the file.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.