Check API file uploads with magic numbers in Node.js
An upload named avatar.png is not necessarily a PNG. This walkthrough runs a local Node.js endpoint
that identifies PNG and JPEG uploads from their bytes, rejects other detected types, and enforces
file and request byte limits. A successful response means the signature matched the allowlist, not
that the image is valid or safe.
Why file extension validation isn’t enough
The filename and the file part’s Content-Type are supplied by the client. Renaming a file or
declaring image/png does not change its contents. The endpoint below deliberately ignores both
when deciding whether the detected type is allowed; it never uses a client filename as a storage
path. OWASP’s upload guidance
explains why client MIME types cannot be a security boundary.
Understand magic numbers and file signatures
Magic numbers are recognizable byte sequences that suggest a file’s format. A PNG begins with
89 50 4E 47 0D 0A 1A 0A; a JPEG starts with FF D8 FF. A detection library knows more format
details than a short hand-written prefix check, but it still does not decode the complete image.
The file-type documentation
describes detection as a best-effort hint, not proof of file validity. Some damaged files produce no
match or throw; others still have a recognizable signature. Do not name the result valid or use
it as a malware verdict.
Implement magic-number validation in Node.js
Use Bash on Linux, cURL, Node.js 24.15.0 or a newer release in the 24 LTS line, and Corepack with Yarn 4.12.0 available. The example was tested with Node.js 24.15.0 and 26.8.1. Use a current patched LTS release for deployment, rather than treating the tested minimum as a security-update recommendation.
Start in a writable directory. This creates a new magic-upload project without changing your
shell’s working directory. It refuses an existing project directory, and stops before installation
if creation or navigation fails. The local yarn.lock establishes a separate Yarn project; the
node-modules linker lets the plain node command resolve its dependencies, including inside a
Yarn Plug’n’Play parent project.
(
mkdir magic-upload &&
cd magic-upload &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
YARN_NODE_LINKER=node-modules corepack yarn add --exact \
express@4.22.2 file-type@22.0.0 multer@2.4.0
)
Multer 2.4.0 supplies the streamHandler option used below and includes an
upload cleanup security fix.
Keep upload dependencies patched when adapting the example.
Validate size-limited uploads with Express.js
Save the complete program as magic-upload/server.mts. Node’s
native type stripping runs this .mts
file as an ES module without a compiler or a TypeScript loader. It does not type-check the program
or read an enclosing tsconfig.json.
The first middleware uses express.raw
to read the complete request body with a 5 MiB + 16 KiB cap, before multipart
parsing. This covers boundaries, part headers, and trailing body bytes as well as file contents,
including requests without Content-Length. Multer then enforces a separate 5 MiB file limit and
accepts only one file part named file, with no text fields. Its
streamHandler feeds the already-read
body to the multipart parser instead of reading the exhausted request stream again.
import express, { type ErrorRequestHandler, type Request, type Response } from 'express'
import { fileTypeFromBuffer } from 'file-type'
import multer from 'multer'
const app = express()
const maxFileBytes = 5 * 1024 * 1024
const maxRequestBytes = maxFileBytes + 16 * 1024
const allowedTypes = new Set(['image/png', 'image/jpeg'])
async function identifyUpload(request: Request, response: Response): Promise<void> {
if (!request.file || request.file.size === 0) {
response.status(400).json({ error: 'Send one nonempty file in the file field' })
return
}
const type = await fileTypeFromBuffer(request.file.buffer)
if (type === undefined || !allowedTypes.has(type.mime)) {
response.status(415).json({ error: 'Only detected PNG or JPEG files are allowed' })
return
}
response.json({ detectedMime: type.mime, size: request.file.size })
}
app.post(
'/upload',
express.raw({ type: 'multipart/form-data', limit: maxRequestBytes, inflate: false }),
(request, response, next) => {
if (!Buffer.isBuffer(request.body)) {
response.status(415).json({ error: 'Send multipart/form-data' })
return
}
const body = request.body
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: maxFileBytes, files: 1, fields: 0, parts: 1 },
streamHandler: (_request, parser) => parser.end(body),
})
upload.single('file')(request, response, (error: unknown) => {
if (error) return next(error)
void identifyUpload(request, response).catch(() => {
response.status(400).json({ error: 'Unable to identify the file' })
})
})
},
)
const rejectUpload: ErrorRequestHandler = (error: unknown, _request, response, _next) => {
const tooLarge =
(error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') ||
(error instanceof Error && 'type' in error && error.type === 'entity.too.large')
response.status(tooLarge ? 413 : 400).json({
error: tooLarge ? 'Upload exceeds byte limit' : 'Malformed upload',
})
}
app.use(rejectUpload)
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer between 0 and 65535')
}
const server = app.listen(port, '127.0.0.1')
server.on('listening', () => {
const address = server.address()
if (address !== null && typeof address !== 'string') {
console.log(`Listening on http://127.0.0.1:${address.port}`)
}
})
server.on('error', () => {
console.error('Could not start the upload server; check PORT and whether it is in use')
process.exitCode = 1
})
From the same directory where you ran setup, start the foreground server:
node magic-upload/server.mts
Wait for the listening URL before sending requests. If port 3000 is occupied, set PORT to another
available port when running the same command and adjust the request URL to match. Stop the server
with Ctrl+C; this returns to your shell. The endpoint binds to loopback, keeps uploads in memory,
and writes no uploaded files to disk.
Test your implementation
In a second terminal, return to the directory containing magic-upload. Create a one-pixel PNG
with a misleading .bin filename. The create-only write refuses to overwrite an existing
sample.bin; remove that demo file deliberately if you want to recreate it.
node --input-type=module -e '
import { writeFileSync } from "node:fs"
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQI12P4z8DwHwAFAAH/cpxSZwAAAABJRU5ErkJggg=="
writeFileSync("magic-upload/sample.bin", Buffer.from(png, "base64"), { flag: "wx" })
'
Upload it with a deliberately incorrect file-part MIME type:
curl -fsS -F 'file=@magic-upload/sample.bin;type=text/plain' \
http://127.0.0.1:3000/upload
The response is based on the received file bytes, not .bin or text/plain:
{"detectedMime":"image/png","size":70}
Now claim that ordinary text is a PNG. This command prints the HTTP status without treating the expected rejection as a cURL failure:
printf '%s' 'not an image' | curl -sS -o /dev/null -w '%{http_code}\n' \
-F 'file=@-;filename=avatar.png;type=image/png' http://127.0.0.1:3000/upload
Expect 415. Also test these behaviors when adapting the endpoint:
- A valid JPEG with an unrelated filename returns
image/jpegand its actual file byte count. - A missing or empty file returns
400. A broken multipart body or an unexpected field also returns400. - Unknown signatures and recognized but disallowed formats, such as GIF or PDF, return
415. - File contents up to and including 5 MiB pass the size check; one byte more returns
413. - A request body up to and including 5 MiB + 16 KiB passes the request-size check; one byte more
returns
413, even if the extra bytes follow the final multipart boundary. Type and form checks still apply below either limit.
Handle edge cases and polyglot files
A three-byte FF D8 FF prefix can be identified as JPEG without containing a complete image. An
image with damage later in its contents can also pass. The endpoint deliberately reports
detectedMime, not a successful decode or a safety verdict. A polyglot file can be interpreted as
more than one format; searching for suspicious byte sequences inside it is not a reliable detector.
Follow signature identification with a maintained format-specific decoder, dimension and pixel limits, and content scanning or reconstruction appropriate to your threat model. Run untrusted parsers in isolation with enforced execution limits. The local example does none of that, and does not reject every malformed image.
Keep the memory and security boundaries explicit
This is a small-upload demonstration, not a large-file streaming pipeline. The raw body and
Multer’s file buffer coexist, with additional parser and allocation overhead. A file-size cap is
not a bound on total process memory, concurrent uploads, or detection time. Large files need a
different storage path and policy; neither a fixed-length prefix nor a successful signature match
establishes that the rest of an upload has been checked. This walkthrough uses Node.js throughout;
it does not require Python or libmagic.
Combine magic-number validation with other controls
Before exposing an upload endpoint, add authentication, authorization, rate and concurrency limits,
and request deadlines at the appropriate application or proxy boundary. Keep dependencies patched.
If you persist uploads, generate storage names, quarantine files before processing, and avoid
serving active content from your application’s origin. These are separate controls, not properties
of file-type; see OWASP’s complete upload checklist.
Troubleshoot common issues
- No listening URL: check the startup diagnostic,
PORT, and whether the address is occupied. Do not send a test upload to an unrelated service already using that port. - Cannot find a package: finish installation inside
magic-uploadand use the documented.mtsfilename. Do not replace ESM imports with CommonJSrequire(). - Unexpected
400: send exactly one nonempty file namedfileand no additional form fields. An identification exception is also reported without exposing its internal error. - Unexpected
413: check the complete multipart body as well as file size. Part headers and boundaries consume the request’s additional 16 KiB allowance.
Keep the distinction between “identified” and “validated” when you connect this endpoint to storage or processing. For a managed upload workflow, explore our handling uploads service; the policy for what your application accepts still belongs to you.
