Secure Image Processing in Java: EXIF-Free Uploads with Quarkus
Implement fast, safe, metadata-free image handling using Apache Commons Imaging in a production-ready Quarkus service.
Modern applications handle user-generated images every day. Social apps. Customer portals. Identity verification systems. They all accept photos uploaded from phones. Those photos often contain hidden metadata. GPS coordinates. Camera models. Timestamps. Even device serials in some cases.
If your application processes photos and returns them to users or stores them long-term, you need to treat this metadata as sensitive information. Regulatory compliance teams care. Privacy teams care. Users definitely care.
This tutorial shows how to build a minimal but production-ready image metadata stripper using Quarkus, REST, and Apache Commons Imaging.
You will learn how to handle multipart uploads, work with binary streams, and clean images without touching pixels.
Before writing any code, it’s worth understanding why this library is the right fit.
Apache Commons Imaging — formerly Apache Commons Sanselan — is a pure Java library for reading, writing, and extracting metadata from many image formats. It offers several advantages:
Pure Java implementation. No native bindings. No platform-specific quirks. More portable and resilient inside containers and serverless environments.
Safer than native codecs. Native image libraries frequently have memory-safety issues when dealing with corrupted or malicious image files. A pure-Java library avoids entire classes of vulnerabilities.
More formats and better correctness. Imaging handles a wider set of image types than the built-in Java APIs and often parses metadata more reliably.
Simpler API than ImageIO/JAI. Sun/Oracle’s image stack is a mix of ImageIO, JAI, and
java.awt.Toolkit. Imaging offers a single, clean API for reading images and manipulating metadata.Production-tested. The library was used in several real-world applications even before graduating into Apache Commons.
For this tutorial, Imaging gives us one critical feature:
Reading and printing the available metadata.
Everything else stays the same: same pixels, same quality.
This makes it the perfect choice for a privacy-focused image processing microservice running on Quarkus.
Let’s build it.
Prerequisites
You need:
Java 21 or newer.
Apache Maven 3.9+.
Quarkus CLI (optional but recommended).
Basic knowledge of JAX-RS
Bootstrapping the Project
Use the Quarkus CLI:
quarkus create app com.example:metadata-stripper \
--extensions=rest-jackson
cd metadata-stripperAdd Apache Commons Imaging to your pom.xml:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-imaging</artifactId>
<version>1.0.0-alpha6</version>
</dependency>And, as usual, feel free to grab the complete project from my Github repository. Leave a star while you’re there ;-)
Understanding the Core Idea
Images often carry metadata. JPEG is the biggest offender, embedding EXIF blocks that contain:
GPS coordinates
Camera model, lens type
Timestamps
Software used to edit the image
Serial numbers
Commons Imaging can read images, extract metadata and show you what is actually contained in your images.
The technique:
Read the uploaded
byte[].Parse and log with Commons Imaging.
Re-encode the image without metadata.
Return the cleaned version as a binary stream.
Implementing the Upload Endpoint
Rename the scaffolded GreetingResource and replace with below implementation:
src/main/java/com/example/ImageCleanerResource.java
package com.example;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Map;
import javax.imageio.ImageIO;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/clean”)
public class ImageCleanerResource {
@Inject
Logger log;
@Inject
MetadataReader metadataReader;
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(”image/jpeg”)
public Response cleanImage(@FormParam(”file”) FileUpload file) {
if (file == null) {
throw new WebApplicationException(”Missing file upload”, 400);
}
String filename = file.fileName();
byte[] originalBytes;
try {
originalBytes = Files.readAllBytes(file.uploadedFile());
} catch (IOException e) {
log.errorf(e, “Failed to read uploaded file ‘%s’: %s”, filename, e.getMessage());
throw new WebApplicationException(”Failed to read uploaded file: “ + e.getMessage(),
Response.Status.BAD_REQUEST);
}
// Log metadata before stripping
logMetadata(”BEFORE”, originalBytes, filename);
// Check if image has metadata - if not, return original without processing
if (!hasMetadata(originalBytes)) {
log.debugf(”No metadata found in file ‘%s’, returning original image”, filename);
logMetadata(”AFTER”, originalBytes, filename);
return Response.ok(originalBytes, MediaType.valueOf(”image/jpeg”))
.header(”Content-Disposition”, “attachment; filename=\”“ + filename + “\”“)
.build();
}
// Read image (ImageIO automatically excludes metadata)
BufferedImage image;
try {
image = ImageIO.read(new ByteArrayInputStream(originalBytes));
if (image == null) {
throw new IOException(”Failed to read image”);
}
log.debugf(”Read image: %dx%d”, image.getWidth(), image.getHeight());
} catch (IOException e) {
log.errorf(e, “Failed to read image from file ‘%s’: %s”, filename, e.getMessage());
throw new WebApplicationException(”Failed to read image: “ + e.getMessage(),
Response.Status.INTERNAL_SERVER_ERROR);
}
// Write image as JPEG (no metadata)
byte[] outputBytes;
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (!ImageIO.write(image, “jpg”, baos)) {
throw new IOException(”No JPEG writer available”);
}
outputBytes = baos.toByteArray();
log.debugf(”Wrote JPEG image: %d bytes”, outputBytes.length);
} catch (IOException e) {
log.errorf(e, “Failed to write JPEG image for file ‘%s’: %s”, filename, e.getMessage());
throw new WebApplicationException(”Failed to write image: “ + e.getMessage(),
Response.Status.INTERNAL_SERVER_ERROR);
}
// Log metadata after stripping
logMetadata(”AFTER”, outputBytes, filename);
return Response.ok(outputBytes, MediaType.valueOf(”image/jpeg”))
.header(”Content-Disposition”, “attachment; filename=\”“ + filename + “\”“)
.build();
}
private void logMetadata(String stage, byte[] imageBytes, String filename) {
try {
Map<String, Object> metadata = metadataReader.readMetadata(imageBytes);
log.infof(”Metadata %s stripping for file ‘%s’: %s”, stage, filename, metadata);
} catch (IOException e) {
log.warnf(”Failed to read metadata %s stripping for file ‘%s’: %s”, stage, filename, e.getMessage());
}
}
private boolean hasMetadata(byte[] imageBytes) {
try {
Map<String, Object> metadata = metadataReader.readMetadata(imageBytes);
Boolean hasMetadata = (Boolean) metadata.get(”hasMetadata”);
return hasMetadata != null && hasMetadata;
} catch (IOException e) {
log.debugf(e, “Failed to check metadata, assuming metadata exists: %s”, e.getMessage());
return true;
}
}
}Highlights
@Consumes(MediaType.MULTIPART_FORM_DATA)handles form-based uploads.FileUploadfrom RESTEasy Reactive gives you file metadata and access to the temp file.Imaging.getBufferedImage()reads any supported format.Rewriting the image without EXIF produces a clean version.
Logging Metadata
The MetadataReader is an application-scoped CDI bean that reads and extracts metadata from images using Apache Commons Imaging.
src/main/java/com/example/MetadataReader.java
package com.example;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.ImagingException;
import org.apache.commons.imaging.common.ImageMetadata;
import org.apache.commons.imaging.common.ImageMetadata.ImageMetadataItem;
import org.apache.commons.imaging.common.RationalNumber;
import org.apache.commons.imaging.formats.jpeg.JpegImageMetadata;
import org.apache.commons.imaging.formats.tiff.TiffField;
import org.apache.commons.imaging.formats.tiff.TiffImageMetadata;
import org.apache.commons.imaging.formats.tiff.constants.ExifTagConstants;
import org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants;
import org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants;
import org.apache.commons.imaging.formats.tiff.taginfos.TagInfo;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class MetadataReader {
/**
* Reads metadata from image bytes and returns a structured representation.
* Based on the Apache Commons Imaging MetadataExample.
*
* @param imageBytes the image data as byte array
* @return a map containing metadata information
* @throws ImagingException if metadata cannot be read
* @throws IOException if I/O error occurs
*/
public Map<String, Object> readMetadata(byte[] imageBytes) throws ImagingException, IOException {
Map<String, Object> metadataMap = new HashMap<>();
// Get all metadata stored in EXIF format (ie. from JPEG or TIFF)
final ImageMetadata metadata = Imaging.getMetadata(imageBytes);
if (metadata == null) {
metadataMap.put(”hasMetadata”, false);
return metadataMap;
}
metadataMap.put(”hasMetadata”, true);
metadataMap.put(”metadataType”, metadata.getClass().getSimpleName());
if (metadata instanceof JpegImageMetadata) {
final JpegImageMetadata jpegMetadata = (JpegImageMetadata) metadata;
// Extract common EXIF tags
Map<String, String> exifTags = new HashMap<>();
extractTagValue(jpegMetadata, TiffTagConstants.TIFF_TAG_XRESOLUTION, exifTags);
extractTagValue(jpegMetadata, TiffTagConstants.TIFF_TAG_YRESOLUTION, exifTags);
extractTagValue(jpegMetadata, TiffTagConstants.TIFF_TAG_DATE_TIME, exifTags);
extractTagValue(jpegMetadata, TiffTagConstants.TIFF_TAG_MAKE, exifTags);
extractTagValue(jpegMetadata, TiffTagConstants.TIFF_TAG_MODEL, exifTags);
extractTagValue(jpegMetadata, TiffTagConstants.TIFF_TAG_ORIENTATION, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_DATE_TIME_ORIGINAL, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_DATE_TIME_DIGITIZED, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_ISO, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_SHUTTER_SPEED_VALUE, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_APERTURE_VALUE, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_BRIGHTNESS_VALUE, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_FOCAL_LENGTH, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_LENS_MAKE, exifTags);
extractTagValue(jpegMetadata, ExifTagConstants.EXIF_TAG_LENS_MODEL, exifTags);
metadataMap.put(”exifTags”, exifTags);
// Extract GPS information
Map<String, Object> gpsInfo = extractGpsInfo(jpegMetadata);
if (!gpsInfo.isEmpty()) {
metadataMap.put(”gps”, gpsInfo);
}
// Extract all metadata items
List<String> allItems = new ArrayList<>();
final List<ImageMetadataItem> items = jpegMetadata.getItems();
for (final ImageMetadataItem item : items) {
allItems.add(item.toString());
}
metadataMap.put(”allItems”, allItems);
}
return metadataMap;
}
/**
* Extracts a specific tag value from JPEG metadata.
*/
private void extractTagValue(final JpegImageMetadata jpegMetadata, final TagInfo tagInfo,
Map<String, String> exifTags) {
final TiffField field = jpegMetadata.findExifValueWithExactMatch(tagInfo);
if (field != null) {
exifTags.put(tagInfo.name, field.getValueDescription());
}
}
/**
* Extracts GPS information from JPEG metadata.
*/
private Map<String, Object> extractGpsInfo(final JpegImageMetadata jpegMetadata) {
Map<String, Object> gpsMap = new HashMap<>();
try {
// Simple interface to GPS data
final TiffImageMetadata exifMetadata = jpegMetadata.getExif();
if (exifMetadata != null) {
final TiffImageMetadata.GpsInfo gpsInfo = exifMetadata.getGpsInfo();
if (gpsInfo != null) {
gpsMap.put(”description”, gpsInfo.toString());
gpsMap.put(”longitude”, gpsInfo.getLongitudeAsDegreesEast());
gpsMap.put(”latitude”, gpsInfo.getLatitudeAsDegreesNorth());
}
}
} catch (ImagingException e) {
// Ignore if GPS info cannot be extracted
}
// More specific GPS field extraction
try {
final TiffField gpsLatitudeRefField = jpegMetadata
.findExifValueWithExactMatch(GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF);
final TiffField gpsLatitudeField = jpegMetadata
.findExifValueWithExactMatch(GpsTagConstants.GPS_TAG_GPS_LATITUDE);
final TiffField gpsLongitudeRefField = jpegMetadata
.findExifValueWithExactMatch(GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF);
final TiffField gpsLongitudeField = jpegMetadata
.findExifValueWithExactMatch(GpsTagConstants.GPS_TAG_GPS_LONGITUDE);
if (gpsLatitudeRefField != null && gpsLatitudeField != null &&
gpsLongitudeRefField != null && gpsLongitudeField != null) {
try {
final String gpsLatitudeRef = (String) gpsLatitudeRefField.getValue();
final RationalNumber[] gpsLatitude = (RationalNumber[]) gpsLatitudeField.getValue();
final String gpsLongitudeRef = (String) gpsLongitudeRefField.getValue();
final RationalNumber[] gpsLongitude = (RationalNumber[]) gpsLongitudeField.getValue();
if (gpsLatitude != null && gpsLatitude.length >= 3 &&
gpsLongitude != null && gpsLongitude.length >= 3) {
Map<String, Object> detailedGps = new HashMap<>();
detailedGps.put(”latitudeRef”, gpsLatitudeRef);
detailedGps.put(”latitudeDegrees”, gpsLatitude[0].toDisplayString());
detailedGps.put(”latitudeMinutes”, gpsLatitude[1].toDisplayString());
detailedGps.put(”latitudeSeconds”, gpsLatitude[2].toDisplayString());
detailedGps.put(”longitudeRef”, gpsLongitudeRef);
detailedGps.put(”longitudeDegrees”, gpsLongitude[0].toDisplayString());
detailedGps.put(”longitudeMinutes”, gpsLongitude[1].toDisplayString());
detailedGps.put(”longitudeSeconds”, gpsLongitude[2].toDisplayString());
gpsMap.put(”detailed”, detailedGps);
}
} catch (ClassCastException e) {
// Ignore if GPS data format is unexpected
}
}
} catch (ImagingException e) {
// Ignore if GPS fields cannot be found
}
return gpsMap;
}
/**
* Checks if the image has any metadata.
*
* @param imageBytes the image data as byte array
* @return true if metadata is present, false otherwise
*/
public boolean hasMetadata(byte[] imageBytes) {
try {
final ImageMetadata metadata = Imaging.getMetadata(imageBytes);
return metadata != null;
} catch (IOException e) {
return false;
}
}
}Key Features:
Uses Apache Commons Imaging to read EXIF metadata from JPEG/TIFF images
Handles exceptions gracefully (logs debug messages, doesn’t throw)
Returns structured data as a Map<String, Object> for easy logging/processing
Application-scoped, so a single instance is shared across requests
Verification: Try It with Curl
Upload an image with curl:
curl -X POST http://localhost:8080/clean \
-F “file=@test-image.png” \
-D headers.txt \
--output test.pngVerify EXIF removal: (brew install exiftool)
exiftool clean-sample.jpgExpected output:
No GPS fields. No camera model. Only minimal technical headers.
ExifTool Version Number : 13.36
File Name : test.jpg
Directory : .
File Size : 303 kB
File Modification Date/Time : 2025:11:26 10:31:31+01:00
File Access Date/Time : 2025:11:26 10:31:44+01:00
File Inode Change Date/Time : 2025:11:26 10:31:31+01:00
File Permissions : -rw-r--r--
File Type : PNG
File Type Extension : png
MIME Type : image/png
Image Width : 375
Image Height : 500
Bit Depth : 8
Color Type : RGB with Alpha
Compression : Deflate/Inflate
Filter : Adaptive
Interlace : Noninterlaced
Image Size : 375x500
Megapixels : 0.188
Optional: Return Base64 Instead of Binary
Some frontend frameworks prefer JSON output:
@Produces(MediaType.APPLICATION_JSON)
public Map<String, String> cleanImageBase64(...)Encoding:
String base64 = Base64.getEncoder().encodeToString(stripped);
return Map.of(”image”, base64);Optional: External Storage Integration
To process files from MinIO/S3 instead of uploads:
Use
quarkus-amazon-s3(different article)Download the object
Apply the same
stripMetadatamethodRe-upload
Where To Go From Here
You now have a fully working image sanitation service.
From here, you can:
Add virus scanning (ClamAV + REST endpoint).
Resize or compress images on upload.
Add a dashboard for upload metrics using Grafana.
Wrap this service into a larger content-processing pipeline.
Quarkus gives you a fast, low-latency environment for working with binary data. Combine it with imaging libraries and you can build powerful media workflows safely.
Clean images. Cleaner privacy.




Your decision to use Apache Commons Imaging instead of native codecs is spot on, especially for production environments. The pure-Java approach really does avoid entire categories of memory corruption vulnerabilities that plague native image libraries. What's intresting though is the tradeoff you're accepting: processing speed versus security hardening. In high-throughput scenarios where you're handling thousands of uploads per second, that overhead becomes measurable, but for most enterprise workloads the safety gain justifies it compleely. The MetadataReader abstraction is clever too becuase it lets you swap implementations later if performance profiling shows bottlenecks.