How to check file type

Read the first bytes instead of the name. file --mime-type photo.jpg prints image/png when a PNG has been renamed, and xxd -l 16 photo.jpg shows the signature 8950 4e47 0d0a 1a0a. The extension and the uploaded Content-Type are both claims made by the client, not facts about the bytes.

Why check this

Run this on every feature that accepts a file, before release and again after the validation library changes. The check separates three things a bug report usually merges: what the file is called, what the request says it is, and what its bytes say it is.

One concrete failure it prevents: a PHP script uploaded as logo.png and stored in a directory the web server can execute. A second: a PNG renamed to .jpg that passes the extension allow list, then breaks the thumbnailer in production because the decoder was picked from the name. Both cases look identical in a test that reads only the file name.

Prerequisites

Create the fixtures. The script writes a valid one-pixel PNG, a byte copy of it named photo.jpg, a PHP script named logo.png, the same script behind a GIF87a header, and the first 8 bytes of the PNG alone.

import { writeFileSync } from 'node:fs';
import { deflateSync } from 'node:zlib';

function chunk(type, data) {
  const len = Buffer.alloc(4);
  len.writeUInt32BE(data.length);
  const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
  const table = [];
  for (let n = 0; n < 256; n += 1) {
    let c = n;
    for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    table[n] = c >>> 0;
  }
  let crc = 0xffffffff;
  for (const b of body) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
  const crcBuf = Buffer.alloc(4);
  crcBuf.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
  return Buffer.concat([len, body, crcBuf]);
}

const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(1, 0);
ihdr.writeUInt32BE(1, 4);
ihdr[8] = 8; ihdr[9] = 2;
const png = Buffer.concat([
  Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  chunk('IHDR', ihdr),
  chunk('IDAT', deflateSync(Buffer.from([0x00, 0xff, 0x00, 0x00]))),
  chunk('IEND', Buffer.alloc(0)),
]);
const script = Buffer.from('\n', 'latin1');

writeFileSync('sample.png', png);
writeFileSync('photo.jpg', png);
writeFileSync('logo.png', script);
writeFileSync('banner.gif', Buffer.concat([Buffer.from('GIF87a', 'latin1'), script]));
writeFileSync('trunc.png', png.subarray(0, 8));

Steps 3 and 4 post to a local endpoint that reports the declared type and the sniffed type side by side. Save it as upload-8931.mjs, start it with node upload-8931.mjs, and stop it when you finish.

import { createServer } from 'node:http';

const SIGNATURES = [
  ['89504e470d0a1a0a', 'image/png'],
  ['ffd8ff', 'image/jpeg'],
  ['474946383761', 'image/gif'],
  ['474946383961', 'image/gif'],
  ['25504446', 'application/pdf'],
  ['504b0304', 'application/zip'],
];

createServer((req, res) => {
  const parts = [];
  req.on('data', (d) => parts.push(d));
  req.on('end', () => {
    const body = Buffer.concat(parts);
    const sep = body.indexOf('\r\n\r\n');
    const head = body.subarray(0, sep).toString('latin1');
    const hex = body.subarray(sep + 4, sep + 12).toString('hex');
    const sniffed = SIGNATURES.find(([sig]) => hex.startsWith(sig));
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify({
      filename: /filename="([^"]*)"/.exec(head)?.[1] ?? null,
      declaredType: /Content-Type:\s*(\S+)/i.exec(head)?.[1] ?? null,
      firstBytesHex: hex,
      sniffedType: sniffed ? sniffed[1] : null,
    }));
  });
}).listen(8931, '127.0.0.1', () => console.log('listening on 8931'));

Steps

  1. Step 1.

    Print the opening bytes of a file you trust, in hex and in ASCII.

    xxd -l 16 sample.png
    
    00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR

    The first eight bytes are the PNG signature. IHDR at offset 12 is the first chunk name, so the header is structurally intact, not only the magic number.

  2. Step 2.

    Ask for the type of every fixture at once and compare it with the name on disk.

    file --mime-type sample.png photo.jpg logo.png banner.gif trunc.png
    
    sample.png: image/png
    photo.jpg:  image/png
    logo.png:   text/x-php
    banner.gif: image/gif
    trunc.png:  application/octet-stream

    Two lines disagree with the extension. photo.jpg is a PNG, and logo.png is source code. file read the bytes and ignored the name in both directions.

  3. Step 3.

    Send the PNG to the endpoint while telling it the part is a JPEG.

    curl -s -F 'file=@photo.jpg;type=image/jpeg' http://127.0.0.1:8931/upload
    
    {"filename":"photo.jpg","declaredType":"image/jpeg","firstBytesHex":"89504e470d0a1a0a","sniffedType":"image/png"}

    declaredType is the string curl was told to write. sniffedType comes from the bytes that arrived. A validator that reads the first field accepts anything a client cares to claim.

  4. Step 4.

    Post the file that carries a GIF header in front of a script.

    curl -s -F 'file=@banner.gif;type=image/gif' http://127.0.0.1:8931/upload
    
    {"filename":"banner.gif","declaredType":"image/gif","firstBytesHex":"4749463837613c3f","sniffedType":"image/gif"}

    Name, declared type and signature agree, and the payload after byte 6 is still <?php. A signature check answers what the file starts with, never what the rest of it holds.

  5. Step 5.

    Run the same signature table over the files directly, the way an assertion in a test suite would.

    node -e "const fs=require('node:fs');const SIG={'89504e470d0a1a0a':'image/png','ffd8ff':'image/jpeg','474946383761':'image/gif','25504446':'application/pdf','504b0304':'application/zip'};for(const f of process.argv.slice(1)){const h=fs.readFileSync(f).subarray(0,8).toString('hex');const t=Object.keys(SIG).find(s=>h.startsWith(s));console.log(f,h,t?SIG[t]:'unknown');}" sample.png photo.jpg logo.png banner.gif trunc.png
    
    sample.png 89504e470d0a1a0a image/png
    photo.jpg 89504e470d0a1a0a image/png
    logo.png 3c3f706870206563 unknown
    banner.gif 4749463837613c3f image/gif
    trunc.png 89504e470d0a1a0a image/png

    Compare the last line with step 2. An eight-byte file is image/png to the signature table and application/octet-stream to file, because file looks for a structure and the table looks for a prefix.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The type matches the extension | Name and bytes agree on this file | Nothing. Keep the check in the suite. | | photo.jpg: image/png | The name is wrong or the file was renamed | Decide by bytes everywhere downstream, and store the type you detected. | | logo.png: text/x-php | An executable text file wearing an image name | Treat as an incident on a live upload directory. Check How to check if file type is validated on the server. | | declaredType and sniffedType differ | The client lied, or the browser guessed from the extension | Reject on the server. The request header is not evidence. | | application/octet-stream | No rule in the magic database matched | Read the bytes yourself before deciding. It can be a new format or a broken file. |

Common mistakes

Sign: file reports GIF image data, version 87a, 16188 x 26736 and the upload still executes as a script.Cause: A GIF is identified by six bytes. Everything after them was the PHP source, and the width and height in that line were parsed out of the script text. Six matching bytes are an entry ticket, not a scan.
Sign: A magic-byte assertion passes and the image library then fails to open the file.Cause: A prefix check reads 8 bytes and stops. The 8-byte trunc.png fixture is image/png to the table in step 5 and application/octet-stream to file 5.44, which wants a parsable header behind the signature. Decode the file if the pipeline will decode it.
Sign: The upload endpoint accepts a file whose Content-Type was set by hand.Cause: curl writes whatever follows ;type= into the multipart part, and a browser fills that header from the extension registry on the client machine. Step 3 shows image/jpeg on a PNG body. Nothing in the request forces the two to agree.

Thresholds

8 bytes: the PNG signature 89 50 4E 47 0D 0A 1A 0A that begins every PNG file, visible as the first line of step 1. Source: RFC 2083, section 3.1, PNG file signature
6 bytes: the GIF signature, GIF87a or GIF89a, which is all that banner.gif needed to be reported as image/gif by file 5.44 and by the endpoint in step 4. Source: Measured on 2026-09-12, see the Verified block

What to check next

FAQ

How to check the MIME type of a file?

file --mime-type name.ext prints it from the bytes. Without --mime-type the same command prints a longer description, including image dimensions and colour depth. Both read the magic database, never the extension, so a renamed file gives the same answer as the original.

How to check a file type without an extension?

The same commands work. Nothing in step 1, 2 or 5 uses the name for anything except opening the file.

How to check a PNG file header?

xxd -l 16 file.png. The first eight bytes are 8950 4e47 0d0a 1a0a. Bytes 12 to 15 spell IHDR, the first chunk. If those four are missing, the signature was copied onto something that is not a PNG.

Is a file signature check enough on its own?

No. Step 4 built a file that passes it and holds a script. Use the signature to reject fast, then decode or re-encode the image, store it under a name you generate, and serve it from a location that cannot execute.

Does the Content-Type in the upload request tell you anything?

It tells you what the client claimed. Step 3 sent PNG bytes labelled image/jpeg and the server recorded both. Treat the header as an input to validate, in the same class as the file name.

Verified

Verified by Maks Vernyfile 5.44curl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic8 minpublished updated Maks Verny