You generate a hero image with an image model, download it, drop it into src/assets/ and build the site. Astro scales it to three widths, converts it to WebP and writes the files into dist/_astro/. What arrives in the browser afterwards is an image without EXIF, without XMP, without an ICC profile and without a C2PA manifest. The marker that the provider wrote in at generation time is gone. Not because something broke, but because that is the intended behaviour.
This is not an Astro problem. It is a sharp problem, and sharp sits inside almost every JavaScript build process that touches images.
Photo: iantalmacs / Unsplash
Four metadata blocks, four different fates
Before we get to the pipeline, the distinction is worth making, because it explains why one rescue measure does not rescue everything.
EXIF is the classic camera block. Camera model, exposure, orientation, copyright. With AI images, some providers write provenance information in here.
XMP is RDF/XML and the place where the IPTC properties live. For AI labelling exactly one of them matters: Iptc4xmpExt:DigitalSourceType with the controlled value http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia. The property comes from IPTC Photo Metadata version 1.1 from 2009, and the current standard revision is 2025.1 from 26 November 2025. It is the leanest machine-readable marker that exists, and the only one that works without a signing certificate.
ICC is the colour profile. It has nothing to do with AI labelling, but it disappears in the same step, and you can see that in the image.
JUMBF is the box a C2PA manifest sits in. Signed, with a certificate chain, with a hash over the image content. The C2PA specification 2.2 lists JPEG, PNG, WebP and GIF as non-BMFF formats with a general box hash, and HEIF and AVIF as BMFF-based formats with a BMFF-based hash. So WebP and AVIF can carry a manifest. Whether it comes along through a conversion is another question.
And then there is a fifth layer that is not metadata at all: pixel-based watermarks such as SynthID. Google describes them as designed to survive cropping, filters and lossy compression. Because they sit in the image content rather than in a header, they are structurally more robust against re-encoding than any metadata block. That is not a guarantee for one specific build pipeline, and you can only check them with Google’s own tooling, not with exiftool.
What sharp does on encode
The sharp documentation is unambiguous here. On keepMetadata it says: “The default behaviour, when keepMetadata is not used, is to convert to the device-independent sRGB colour space and strip all metadata, including the removal of any ICC profile.” On toBuffer and toFile it adds: “By default all metadata will be removed, which includes EXIF-based orientation.”
All of it. Not selectively, not depending on the format. Configure nothing and you get a bare image.
Since sharp 0.33.0 there are antidotes: keepMetadata(), keepExif(), keepIccProfile(), withExif(), withExifMerge() and withIccProfile(). XMP arrived later, keepXmp() and withXmp() only with 0.34.3 from 10 July 2025. The current version is 0.35.3 from 1 July 2026.
// sharp: the three lines this is all about
await sharp(input)
.resize({ width: 1600 })
.keepMetadata() // since 0.33.0: EXIF, ICC, XMP, IPTC
.webp({ quality: 82 })
.toFile('out.webp');
// more finely dosed:
await sharp(input)
.keepXmp() // since 0.34.3
.keepIccProfile() // since 0.33.0
.webp({ quality: 82 })
.toBuffer();
Two format limits are in the documentation and they matter. withXmp() is explicitly restricted to PNG, JPEG, WebP and TIFF, AVIF is not on the list. And keepExif() carries the note that EXIF is not supported for TIFF output. If you serve AVIF, do not assume that keepMetadata() does the same job there as it does for WebP.
Photo: introspectivedsgn / Unsplash
The proof is in the Astro source
packages/astro/src/assets/services/sharp.ts is short enough to read in one sitting. The chain looks like this:
// packages/astro/src/assets/services/sharp.ts (main branch, abridged)
const result = sharp(inputBuffer, {
failOn: 'none',
pages: -1,
limitInputPixels: config.service.config.limitInputPixels,
});
// always call rotate to adjust for EXIF data orientation
result.rotate();
// ... resize() / flatten() ...
if (outputFormat === 'webp') {
result.webp(encoderOptions as WebpOptions | undefined);
} else if (outputFormat === 'avif') {
result.avif(encoderOptions as AvifOptions | undefined);
}
({ data, info } = await result.toBuffer({ resolveWithObject: true }));
// no keepMetadata(), no keepExif(), no keepXmp(), no keepIccProfile()
Not a single keep call in the whole file. So sharp’s default behaviour applies in full. The default output format is WebP, fixed as DEFAULT_OUTPUT_FORMAT = 'webp' in packages/astro/src/assets/consts.ts.
You cannot turn this off through configuration. This is the complete room for manoeuvre Astro offers for sharp:
export interface SharpImageServiceConfig {
limitInputPixels?: SharpOptions['limitInputPixels'];
kernel?: ResizeOptions['kernel'];
jpeg?: JpegOptions;
png?: PngOptions;
webp?: WebpOptions;
avif?: AvifOptions;
}
Encoder options and a pixel limit. Nothing else.
Next.js makes the same call
If you think this is an Astro quirk, packages/next/src/server/image-optimizer.ts has the same structure:
// packages/next/src/server/image-optimizer.ts (canary, abridged)
const transformer = sharp(buffer, {
limitInputPixels,
sequentialRead: sequentialRead ?? undefined,
})
.timeout({ seconds: timeoutInSeconds ?? 7 })
.rotate();
if (contentType === AVIF) {
transformer.avif({
quality: Math.max(Math.round(quality * (50 / 80)), 1),
effort: 3,
});
} else if (contentType === WEBP) {
transformer.webp({ quality });
}
const optimizedBuffer = await transformer.toBuffer();
No keepMetadata, no withMetadata, no keepIccProfile. The only difference to Astro is when it happens. Astro converts at build time, Next.js on the first request to the optimizer. The result is identical. Next.js also converts to WebP by default, and AVIF has to be enabled explicitly through images.formats.
The pattern is not limited to Node. Pillow lists exif, xmp and icc_profile for WebP and AVIF as optional save parameters that have to be passed in. The documentation does not provide for any automatic carry-over. Push a PNG to WebP through Pillow without setting those parameters and you throw away the same things sharp does.
Photo: florianolv / Unsplash
keepMetadata does not save C2PA
This is where the easy route ends. According to the documentation, keepMetadata() covers EXIF, ICC, XMP and IPTC. C2PA is not on that list, and for a hard reason: libvips, the engine underneath sharp, cannot do JUMBF.
Issue libvips#4420, titled “add C2PA support”, was filed by maintainer John Cupitt himself on 15 March 2025. It is open and assigned to milestone 8.19. The starting point was discussion #4233, which Tim Bray opened on 31 October 2024. As long as that stays the case, a C2PA manifest is gone after every sharp pass, no matter how you configure it.
The C2PA specification is aware of the problem and answers it with soft bindings and Durable Content Credentials. In essence: if a manifest is removed from an asset but a copy sits in a provenance store, manifest and asset can be matched up again through soft bindings. Whether that works in practice at Google or OpenAI is a question of implementation, not of specification. I would not rely on it.
OpenAI concedes the fragility itself. Images from ChatGPT, Codex and the API carry C2PA metadata and a SynthID watermark, but most social platforms strip metadata on upload, and a screenshot is enough anyway. On 20 November 2025 Google announced that images from Nano Banana Pro in the Gemini app, in Vertex AI and in Google Ads get C2PA metadata embedded. For all Gemini image models, the only thing documented so far is the SynthID watermark.
Three configurations that let the marker survive
The public bypass. The simplest route. Astro’s documentation is clear on this: files in the public/ folder are always served or copied unchanged, without any processing, and images in there are never optimised. Scale and compress the image correctly yourself beforehand and you get exactly the bytes you put in, manifest included. The price is that you have to build responsive variants and hashing yourself. For a handful of labelled images, that is the most pragmatic route. In Next.js the equivalent is the unoptimized prop:
import Image from 'next/image';
<Image src="/ai/hero.webp" width={1600} height={900} alt="..." unoptimized />
// or globally in next.config.js:
module.exports = { images: { unoptimized: true } };
Your own image service. Astro allows this through image.service.entrypoint. A local service has to implement getURL(), parseURL() and transform() and return { data, format }. The trick is to spread the built-in sharp service and replace only transform().
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
image: {
service: {
entrypoint: './src/image-service-keepmeta.ts',
config: { limitInputPixels: false },
},
},
});
// src/image-service-keepmeta.ts
// Built on the documented Astro image service API.
import type { LocalImageService } from 'astro';
import sharpService from 'astro/assets/services/sharp';
import sharp from 'sharp';
const service: LocalImageService = {
...sharpService,
async transform(inputBuffer, options, imageConfig) {
const format = options.format ?? 'webp';
const pipeline = sharp(inputBuffer, { failOn: 'none', pages: -1 })
.rotate()
.keepMetadata();
if (options.width || options.height) {
pipeline.resize({
width: options.width ? Math.round(options.width) : undefined,
height: options.height ? Math.round(options.height) : undefined,
withoutEnlargement: true,
});
}
const { data, info } = await pipeline
.toFormat(format, { quality: Number(options.quality) || 82 })
.toBuffer({ resolveWithObject: true });
return { data: new Uint8Array(data), format: info.format };
},
};
export default service;
That rescues XMP, IPTC, ICC and the EXIF block. One restriction remains. The rotate() without an angle orients the image from the EXIF data and, according to the sharp documentation, removes the Orientation tag itself in the process. So that one field does not come through even with keepMetadata(), which is consistent, because the rotation then sits in the pixels. C2PA is still not rescued by any of it.
Set the marker yourself instead of hoping. If the provider’s provenance marker does not make it through anyway, you can write the IPTC property yourself. That is independent of the source image and works deterministically.
const xmp = `<?xml version="1.0"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/">
<Iptc4xmpExt:DigitalSourceType>http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia</Iptc4xmpExt:DigitalSourceType>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`;
await sharp(input)
.withXmp(xmp) // since 0.34.3, PNG/JPEG/WebP/TIFF only
.withExifMerge({ IFD0: { Copyright: 'EMIT Solution' } })
.webp({ quality: 82 })
.toFile('out.webp');
Check the namespace URI against the current IPTC specification before you use it. And if you genuinely need C2PA, re-sign after the build with c2patool from the c2pa-rs project. Its format list contains avif, webp, png, jpg, tiff, heic, gif, svg and jxl, with PDF read-only. For that you need a signing certificate.
Verify instead of hoping
The most important step comes last, and it takes ten seconds. Look inside the build artefact, not the source file.
# What is actually still in the delivered image
exiftool -a -G1 dist/_astro/image.hash.webp
# Check C2PA separately, exiftool does not recognise the JUMBF box as such
c2patool dist/_astro/image.hash.webp
# Reattach the manifest after the build (signing certificate required)
c2patool image.webp --manifest manifest.json --output image.signed.webp
Silent parameter discarding in image pipelines is real, and I documented a case of it on my own setup. On 19 July 2026 I found a bug in my image generation tool where the image configuration was passed to ai.models.generateContent() as generationConfig instead of config. The @google/genai SDK discards that field silently, without an error. The consequence: every image produced up to that point came out at roughly 1K, typically 1365x768 or 1026x1024, even though 2K or 4K had been requested. After the fix, --size 4K at 16:9 actually delivers 5504x3072. The lesson has been in my documentation ever since: when you suspect ignored parameters, measure the output file rather than trusting the CLI output. The same applies to metadata.
Incidentally, my own image pipeline for the blog loses the metadata one step before Astro even gets involved. The generated PNG is converted to WebP at 1600px width through Pillow, without exif, xmp or icc_profile parameters. So the loss does not start in the framework, it starts in my own script.
Who is liable for what
This needs to be kept apart cleanly, because a fair amount of it gets muddled online.
Article 50(2) of the AI Act (Regulation (EU) 2024/1689) explicitly addresses providers of AI systems. In its wording: “Providers of AI systems, including general-purpose AI systems, generating synthetic audio, image, video or text content, shall ensure that the outputs of the AI system are marked in a machine-readable format and detectable as artificially generated or manipulated.” The addressee is the provider, meaning OpenAI, Google, Adobe. Not the developer and not the site operator. Article 50(2) does not create a duty of your own to keep a machine-readable marker in the delivered image.
What does sit with the operator is Article 50(4) subparagraph 1: the disclosure duty for AI-generated or manipulated images and videos that appear real. That is a visible label for humans, not a metadata question. In client projects I solve it with an info button right at the image that opens a popover with explanatory text on click, framed positively rather than as a warning. I described how that is put together in Labelling AI content on your website. Under Article 99(4), the fine range for infringements of Article 50 goes up to 15 million euros or 3 per cent of annual turnover, and the duty applies from 2 August 2026.
Photo: markusspiske / Unsplash
The voluntary part is relevant nonetheless. The “Code of Practice on Transparency of AI-Generated Content” from the EU AI Office was published in final form on 10 June 2026 and covers Article 50(2), (4) and (5), split into a section for providers and one for operators. Measure 1.2 requires signatories to preserve existing metadata markers and not to alter or remove them deliberately. A build that silently strips is not a deliberate removal. But anyone who orients themselves by that line should know that their deployment undercuts it.
The actual reason to fix this is a practical one. If you ever want to prove where an image came from, you need the chain. If it breaks in the build step, it is gone, and it goes unnoticed, because no tool raises an alarm.
If you want to know what your pipeline does to your images, send me a URL to a built image. A look with exiftool and c2patool shows within minutes whether EXIF, XMP and ICC are still in there and whether a manifest survived. If it makes sense, I will set up the image service or the public bypass for you straight away.
FAQ
Does sharp really remove all metadata if I configure nothing?+
Yes. The sharp documentation on keepMetadata puts it this way: without that method the image is converted to sRGB and all metadata including the ICC profile is stripped. The note on toBuffer and toFile repeats it explicitly, EXIF orientation included. This applies to every output format, not just WebP.
Can I switch this off in Astro through astro.config.mjs?+
No. Astro's SharpImageServiceConfig interface exposes only limitInputPixels, kernel and the encoder options for jpeg, png, webp and avif. There is no metadata switch in there. Changing the behaviour means either using the public folder or writing your own image service via image.service.entrypoint.
Does a C2PA manifest survive conversion to WebP?+
Technically it could, because the C2PA specification 2.2 lists WebP and AVIF as supported formats. In practice it does not when sharp does the converting: libvips has no C2PA or JUMBF support, and the corresponding issue has been open since March 2025. keepMetadata() changes nothing about that either.
Am I breaking the AI Act if my build removes the marker?+
Article 50(2) of the AI Act explicitly addresses providers of AI systems, meaning OpenAI, Google or Adobe, not the operator of a website. Operators fall under Article 50(4) with the visible disclosure duty. The voluntary Code of Practice from the EU AI Office does, however, require its signatories in Measure 1.2 to preserve existing metadata markers and not to remove them deliberately.
Want to know more?
In a free intro call we discuss how you can use these topics for your company. Not a sales pitch, but an honest assessment.
Book a free intro call



