Image File Signatures: PNG, JPEG, WebP, AVIF and More

2026-09-17 · 993 words

Give a decoder three things — a filename ending in .png, a header saying image/jpeg, and a byte stream that starts with FF D8 FF — and only one of them is worth trusting. The extension is a naming convention that survives any rename. The declared MIME type is a claim written by whoever produced the data, and it is wrong often enough that treating it as fact is how you end up with a file that opens in your browser and breaks in every other tool.

The byte stream is the only part that cannot lie, because those bytes are what a decoder has to draw.

What a signature actually is

Every container format begins with a fixed pattern so that readers can identify it without parsing the whole file. The pattern is at a known offset — usually byte 0, sometimes byte 4 — and it is short: two to twelve bytes.

PNG    89 50 4E 47 0D 0A 1A 0A     then an IHDR chunk
JPEG   FF D8 FF                     then a segment marker
GIF    47 49 46 38   ("GIF8")       then 39 or 37 for version
WebP   52 49 46 46 __ __ __ __ 57 45 42 50    ("RIFF" size "WEBP")
BMP    42 4D         ("BM")
TIFF   49 49 2A 00 (little-endian) / 4D 4D 00 2A (big-endian)
ICO    00 00 01 00
AVIF   66 74 79 70 at offset 4, major brand "avif"
HEIC   66 74 79 70 at offset 4, major brand "heic" or "mif1"
JP2    00 00 00 0C 6A 50 20 20 0D 0A 87 0A
PSD    38 42 50 53  ("8BPS")

Two details in that list explain most of the confusion people run into.

The first is that PNG's signature contains 0D 0A — a CRLF — and 1A, a DOS end-of-file marker. Both are there deliberately: they were chosen in the 1990s so that a PNG file transferred through a text-mode FTP connection would be visibly corrupted rather than silently mangled. It is also why a PNG's first bytes look like text control characters in a hex dump.

The second is that AVIF, HEIC and HEIF share the same container. They are all ISO base media files, so all of them begin with an ftyp box; what distinguishes them is the four-character brand at offset 8. A decoder that only checks for ftyp will happily report "HEIC" for an AVIF file. This is why a correct implementation returns a ranked list of candidates rather than one answer, and why our Base64 to Image tool reports the evidence (ftyp box + major brand "avif") instead of just a format name.

Reading the bytes in JavaScript

Four lines, no dependencies. Take the first twelve bytes after decoding and compare them:

const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const head = bytes.subarray(0, 12);

const eq = (offset, ...sig) => sig.every((b, i) => head[offset + i] === b);
const ascii = (offset, len = 4) => String.fromCharCode(...head.subarray(offset, offset + len));

if (eq(0, 0x89, 0x50, 0x4e, 0x47)) return 'image/png';
if (eq(0, 0xff, 0xd8, 0xff)) return 'image/jpeg';
if (eq(0, 0x47, 0x49, 0x46, 0x38)) return 'image/gif';
if (eq(0, 0x52, 0x49, 0x46, 0x46) && ascii(8) === 'WEBP') return 'image/webp';
if (ascii(4) === 'ftyp') {
  const brand = ascii(8);
  if (brand === 'avif' || brand === 'avis') return 'image/avif';
  if (brand === 'heic' || brand === 'heix' || brand === 'mif1') return 'image/heic';
}
return null;

Note what happens after the signature check: head is only the first twelve bytes, so this identifies the container, not the contents. A signature says "this is a PNG stream", never "this PNG is complete". Those are different questions, and conflating them is behind a large share of "the file looks fine but the image is half grey" reports — JPEG in particular has no total-length field in its header, so a truncated JPEG still looks like a perfectly legal JPEG for the first few kilobytes.

The four impostors you will actually meet

When a decoder reports "not an image", the more useful question is what is it. In practice, four families account for almost everything that arrives where a picture was expected.

Telling someone "it decoded to 200 KB but the bytes are gzip" is a different, and much more useful, statement than "invalid input".

Why a signature check belongs before the <img> element

If you assign decoded bytes to an <img> and the format is wrong, the browser fails silently: a broken-image icon, an error event, and nothing in the console. No exception, no diagnostic. A signature check converts that silence into a sentence, and it costs microseconds because it reads twelve bytes.

The same check is what makes it safe to accept raw strings with no data: prefix at all — see the raw string decoder — because the format can be established from the bytes rather than from a header that may not exist.

It is also the check that turns a silent failure into a sentence. When a payload decodes but the format is wrong, the useful output is not "invalid image" but "these bytes are gzip"; Base64 image not showing walks through that decision order, and decoding in Python, Node and the browser covers the runtime differences you hit while getting the bytes in the first place.

One caveat about offsets: they are byte offsets into the decoded stream, and Base64 decoding has to be finished first. Whitespace, line breaks and padding in the encoded form do not shift anything after decoding, which is exactly why stripping them first is safe. If you try to reason about signatures on the Base64 text itself, you get confused quickly: three bytes become four characters, so a PNG's eight-byte signature occupies about eleven characters in encoded form, and their values are unrelated to 89 50 4E 47. Decode first, then look.

Signatures are evidence, not guarantees

None of this proves a file is well-formed. A signature check answers "which decoder should I hand this to", and a decoder's own validation answers the rest. That is the right division of labour: the signature is the cheap, reliable first question, and the format's own integrity rules — chunk lengths and a terminating IEND for PNG, the marker chain for JPEG — do the expensive work afterwards.

It also explains why "force this format" is a legitimate escape hatch for the rare file whose signature is wrong but whose contents are fine: real-world data is occasionally mislabelled at the source, and the bytes should be given the last word. Just be aware that you are overriding the only part of the input that was not a claim, so verify the result before shipping it.

Related reading

Try the Base64 to Image converter →