A WhatsApp team inbox does something most web apps avoid: it shows files from strangers inside a logged-in session. Customers send photos, voice notes, PDFs and "documents" of any kind, and an agent opens them in the same browser tab that holds their access to every conversation in the account. If one of those files is served back the wrong way, it stops being a picture and becomes code running as that agent. This post covers what to trust in an inbound WhatsApp media message (very little), how to store it, and the response headers that make serving it safe, with tested TypeScript for each part.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
What the webhook gives you
An inbound image or document message carries a media id, a mime_type, a sha256 and, for documents, a filename (image, document). To get the bytes, you look up the media ID to get a URL and fetch it with your access token. Per Meta's media docs, that URL is valid for five minutes, and media IDs in webhooks expire after seven days. Download promptly and keep your own copy.
Of those fields, decide which you trust:
-
idandsha256: useful. The ID is a key; the hash lets you check the download is the file that was sent. -
mime_type: a description of the file, not a guarantee. Treat it as a hint. -
filename: text the sender chose. It can contain quotes, slashes,..and control characters. - The bytes themselves: the only thing that says what the file really is.
Identify files by their bytes
// Identify a file by its first bytes, not by what the sender said it was. const startsWith = (buf: Buffer, bytes: number[], offset = 0) => bytes.every((b, i) => buf[offset + i] === b); const ascii = (buf: Buffer, start: number, end: number) => buf.subarray(start, end).toString("latin1"); export const sniff = (buf: Buffer): string | null => { if (startsWith(buf, [0xff, 0xd8, 0xff])) return "image/jpeg"; if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"; if (ascii(buf, 0, 4) === "RIFF" && ascii(buf, 8, 12) === "WEBP") return "image/webp"; if (ascii(buf, 0, 5) === "%PDF-") return "application/pdf"; if (ascii(buf, 0, 4) === "OggS") return "audio/ogg"; if (ascii(buf, 0, 3) === "ID3" || startsWith(buf, [0xff, 0xfb])) return "audio/mpeg"; if (ascii(buf, 0, 6) === "#!AMR\n") return "audio/amr"; if (ascii(buf, 4, 8) === "ftyp") { const brand = ascii(buf, 8, 12); if (brand.startsWith("3g")) return "video/3gpp"; if (brand === "M4A ") return "audio/mp4"; return "video/mp4"; } // Office files are zip containers; the zip itself is all we can claim. if (startsWith(buf, [0x50, 0x4b, 0x03, 0x04])) return "application/zip"; return null; }; Most formats you'd show in a chat start with a fixed signature: FF D8 FF for JPEG, %PDF- for PDF, RIFF….WEBP for WebP, ftyp at byte 4 for MP4 and 3GP. Anything you don't recognise returns null, and null means "download it, don't display it". Two things this deliberately doesn't detect: HTML and SVG. Neither belongs in a chat bubble, so neither should ever be served with a type a browser will render.
For a production system, the file-type package covers far more formats. The rule stays the same: the type you serve comes from the bytes and an allow-list, not from the message.
Serve with headers that assume the worst
import { sniff } from "./sniff.ts"; export type WhatsAppMediaType = "image" | "sticker" | "audio" | "video" | "document"; // What each message type may be shown as in the browser. Everything else downloads. const INLINE_OK: Record<WhatsAppMediaType, string[]> = { image: ["image/jpeg", "image/png", "image/webp"], sticker: ["image/webp"], audio: ["audio/ogg", "audio/mpeg", "audio/amr", "audio/mp4", "audio/aac"], video: ["video/mp4", "video/3gpp"], document: [], // documents always download: a PDF or an HTML file is not ours to render }; // A filename for a header: no quotes, slashes or control characters, plus a // UTF-8 version for names in other scripts. export const contentDisposition = (kind: "inline" | "attachment", filename: string | null) => { if (!filename) return kind; const clean = filename.normalize("NFC").replace(/[\x00-\x1f\x7f"\\/]/g, "_").slice(0, 150) || "file"; const asciiOnly = clean.replace(/[^\x20-\x7e]/g, "_"); // RFC 5987: encodeURIComponent leaves ' ( ) * alone, and they aren't allowed here. const encoded = encodeURIComponent(clean).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); return `${kind}; filename="${asciiOnly}"; filename*=UTF-8''${encoded}`; }; export const mediaHeaders = (type: WhatsAppMediaType, bytes: Buffer, declaredMime: string, filename: string | null) => { const actual = sniff(bytes); const inline = actual !== null && INLINE_OK[type].includes(actual); // Worth logging: a mismatch is either a broken client or someone testing you. const mismatch = actual !== null && actual !== declaredMime.split(";")[0].trim().toLowerCase(); const headers = { // The declared type is a claim; only bytes we recognised get a real type. "Content-Type": inline ? actual : "application/octet-stream", "Content-Disposition": contentDisposition(inline ? "inline" : "attachment", filename), "Content-Length": String(bytes.length), "X-Content-Type-Options": "nosniff", // If something does render, it renders with no scripts, no forms and no origin. "Content-Security-Policy": "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; sandbox", "Cross-Origin-Resource-Policy": "same-origin", "Cache-Control": "private, max-age=86400", }; return { headers, mismatch }; }; Each header closes one door:
-
Content-Typefrom the bytes, against an allow-list per message type. An image message may be shown as JPEG, PNG or WebP; a sticker only as WebP; audio and video only as their formats. Anything else isapplication/octet-stream, which no browser renders. -
Content-Disposition: attachmentfor documents, always. A customer's PDF or Word file opens in the agent's own viewer, outside your origin. Only recognised images, audio and video areinline. -
X-Content-Type-Options: nosniffstops the browser second-guessing yourContent-Typeand deciding a file "looks like HTML". - A
Content-Security-Policywithsandboxon media responses. If anything ever does render, it renders in a unique origin with scripts, forms and plugins switched off. -
Cross-Origin-Resource-Policy: same-originkeeps other sites from embedding your customers' files. - A safe filename. Quotes, backslashes, slashes and control characters are replaced, the length is capped, and a UTF-8
filename*keeps names in Hindi, Arabic or any other script readable. Note thatencodeURIComponentleaves',(,)and*alone, and RFC 5987 doesn't allow them unencoded.
The function also returns mismatch: the bytes say one thing and the message said another. Log it. It's either a broken client or someone testing you.
The blob URL trap
Many inboxes don't link to media directly. They fetch it with an auth header, then show it through URL.createObjectURL(blob). A blob URL belongs to the origin that created it: open a blob of HTML in a new tab and it runs as your app, with your app's cookies and storage.
So the same rules apply in the browser:
- Build the blob with the type you decided on the server,
new Blob([bytes], { type: safeType }), never with the message'smime_type. - For anything that isn't an image, audio or video, start a download (
<a download>) instead of opening the blob in a tab. - Keep session tokens in
httpOnlycookies where you can. Script that runs anyway can't read them.
Storing files
import { createHash } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import path from "node:path"; // Customer files live outside anything a web server serves statically, under a // path built only from IDs we control. The sender's filename never touches disk. export const createMediaStore = (root: string) => { const pathFor = (accountId: string, mediaId: string) => { if (!/^[\w-]+$/.test(accountId) || !/^[\w-]+$/.test(mediaId)) throw new Error("Invalid media id"); return path.join(root, accountId, mediaId.slice(0, 2), mediaId); }; const save = async (accountId: string, mediaId: string, bytes: Buffer, expectedSha256: string, maxBytes = 100 * 1024 * 1024) => { if (bytes.length > maxBytes) throw new Error("Media too large"); // The webhook gives a SHA-256; a download that doesn't match isn't the file that was sent. if (createHash("sha256").update(bytes).digest("hex") !== expectedSha256) throw new Error("Checksum mismatch"); const target = pathFor(accountId, mediaId); await mkdir(path.dirname(target), { recursive: true }); // Write to a unique temp file, then rename: a reader never sees half a file. const tmp = `${target}.${randomUUID()}.tmp`; await writeFile(tmp, bytes); await rename(tmp, target); return target; }; const load = (accountId: string, mediaId: string) => readFile(pathFor(accountId, mediaId)); return { save, load, pathFor }; }; - Outside any static folder. If your server serves
uploads/publicly, customer media must not live under it. Serve it through an authenticated route that checks the file belongs to the agent's account. - Paths from your IDs only. The path is account, a two-character shard, and the media ID, checked against a strict pattern. The sender's filename never touches the disk, so
../../etc/passwdis just a name in a header. - Check the SHA-256 from the webhook against what you downloaded.
- Cap the size. WhatsApp's own limit for media is 100 MB; your own may be lower.
- Write to a unique temp file, then rename. A reader never sees half a file, and two writers can't truncate each other's.
Processing untrusted files
Thumbnails and transcoding are where untrusted bytes meet native code. Image and video libraries parse complex formats in C, and their bugs are memory-safety bugs. If you generate thumbnails or convert audio, do it in a separate, low-privilege worker with limits on time, memory and input size, and only for files whose bytes you've already identified. Never hand a raw customer file to a converter just because its mime_type said image/jpeg.
What the tests check
- Signatures: JPEG, WebP, PDF, MP4 and Ogg are recognised; HTML and SVG are not.
- A real JPEG sent as an image is served inline as
image/jpeg. - HTML declared as
image/jpegis served asapplication/octet-stream, as an attachment. - SVG declared as PNG, with a
photo.pngfilename, downloads instead of rendering. - A genuine PDF sent as a document still downloads.
- A JPEG sent as a sticker isn't served as one, and is flagged as a mismatch.
- Every response has
nosniffand a sandboxing CSP. - Filenames: a name containing a quote, CRLF and
Set-Cookieproduces a single safe header; a Devanagari name keeps its UTF-8 form, with(1)percent-encoded;../../etc/passwdloses its slashes. - Storage: files land at
account/12/1234567890with no leftover temp files; a checksum mismatch, a../../evilID and an oversized file are all refused.
Checklist
- Treat
mime_typeandfilenameas claims; trust the bytes and thesha256. - Choose the served type from the bytes and a per-message-type allow-list.
- Serve documents as attachments, always.
- Send
nosniff, a sandboxing CSP andCross-Origin-Resource-Policywith every media response. - Sanitise filenames, and encode them per RFC 5987.
- Build blobs with the safe type, and download rather than open anything that isn't media.
- Store media outside static folders, under paths built from your own IDs.
- Check the hash, cap the size, write atomically.
- Process untrusted files in an isolated worker, if at all.
WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: handling WhatsApp Cloud API webhooks in Node.js and why WhatsApp template sends fail silently.