File Downloads & Attachments
Static middleware is great for assets the browser embeds, but sometimes you need to hand a user an actual file: an invoice PDF, an exported CSV, a generated report. Express gives you two purpose-built response helpers for this — res.download(), which prompts a browser “Save As” dialog, and res.sendFile(), which streams a file inline so it renders in the tab. Both stream from disk efficiently, set the right headers, and clean up after themselves. The catch is that serving files by path is exactly the surface attackers probe for directory traversal, so a few lines of validation separate a feature from a vulnerability.
Triggering a save dialog with res.download
res.download(path, [filename], [options], [callback]) streams a file to the client and sets Content-Disposition: attachment, which is the header that tells the browser “don’t render this, save it.” Under the hood it calls res.sendFile() with that disposition pre-set, so you get streaming, range support, and correct MIME types for free.
import express from "express";
import path from "node:path";
const app = express();
const root = import.meta.dirname;
app.get("/reports/q2", (req, res) => {
const file = path.join(root, "files", "q2-report.pdf");
res.download(file); // prompts "Save As" using the file's own name
});
app.listen(3000);
The response the browser receives makes the behavior explicit:
curl -I http://localhost:3000/reports/q2
Output:
HTTP/1.1 200 OK
Content-Disposition: attachment; filename="q2-report.pdf"
Content-Type: application/pdf
Content-Length: 48210
Accept-Ranges: bytes
Overriding the download filename
Files on disk are often named for the server’s convenience — tmp_8f3a.pdf, export-2026-06.csv — not for the user. Pass a second argument to control the name the browser saves it as, without renaming anything on disk.
app.get("/invoices/:id", async (req, res, next) => {
const file = path.join(root, "invoices", `${req.params.id}.pdf`);
res.download(file, `invoice-${req.params.id}.pdf`, (err) => {
if (err && !res.headersSent) next(err);
});
});
The callback fires when the transfer finishes or fails. This matters because the stream can error after headers are sent (a client disconnects mid-download), so always guard with res.headersSent before reaching for next() — calling it twice throws.
A missing file surfaces as an
ENOENTerror in the callback, not a thrown exception. Without the callback, Express 4 logs the error and the request hangs; Express 5 forwards it to your error middleware automatically. Always pass the callback in 4.x.
Serving files inline with res.sendFile
When you want the browser to display the file — a PDF preview, an image, an HTML fragment — use res.sendFile(). It sends the same streamed response but with Content-Disposition: inline (the default), so the browser renders it in place.
app.get("/preview/:id", (req, res, next) => {
res.sendFile(`${req.params.id}.pdf`, { root: path.join(root, "docs") }, (err) => {
if (err) next(err);
});
});
Note the { root } option. When the path you pass is relative, res.sendFile() requires root to be set and resolves the file beneath it — and it refuses to escape that root, giving you traversal protection by construction. An absolute path is also accepted but skips that guard, so prefer the root form for user-influenced names.
| Helper | Default disposition | Browser behavior | Typical use |
|---|---|---|---|
res.download(path, name) | attachment | ”Save As” dialog | Invoices, exports, archives |
res.sendFile(path, opts) | inline | Renders in the tab | Previews, images, served HTML |
Setting Content-Disposition manually
Both helpers are conveniences over the Content-Disposition header. When you stream a file yourself — say, piping from a generator or a remote source — set the header directly. res.attachment([filename]) is a shortcut that sets disposition to attachment and infers the Content-Type from the extension.
import { createReadStream } from "node:fs";
app.get("/export.csv", (req, res) => {
res.attachment("users-export.csv"); // Content-Disposition + text/csv
res.type("text/csv"); // be explicit anyway
const stream = createReadStream(path.join(root, "tmp", "users.csv"));
stream.pipe(res);
stream.on("error", () => res.sendStatus(500));
});
For names with non-ASCII characters (accents, CJK), encode them so the header stays valid and clients decode them correctly:
const name = "rapport-été.pdf";
res.setHeader(
"Content-Disposition",
`attachment; filename="report.pdf"; filename*=UTF-8''${encodeURIComponent(name)}`
);
Securing file paths
The cardinal rule: never concatenate raw user input into a filesystem path. A request for /files/..%2f..%2f..%2fetc%2fpasswd is a directory-traversal attempt, and naive path.join(dir, req.params.name) will happily resolve outside your directory. Validate first, then resolve, then confirm the result is still inside the intended folder.
app.get("/files/:name", (req, res, next) => {
const baseDir = path.join(root, "downloads");
// 1. Reject anything but a plain filename — no slashes, no dots-only names
if (!/^[a-zA-Z0-9._-]+$/.test(req.params.name)) {
return res.status(400).json({ error: "Invalid filename" });
}
// 2. Resolve and verify the path stays within baseDir
const target = path.resolve(baseDir, req.params.name);
if (!target.startsWith(baseDir + path.sep)) {
return res.status(403).json({ error: "Forbidden" });
}
res.download(target, (err) => {
if (err && !res.headersSent) next(err);
});
});
The two checks are complementary: the allow-list regex blocks traversal sequences early, and the startsWith containment check is the defense-in-depth net that catches anything the regex missed. Returning a generic 403/400 (rather than echoing the path) also avoids confirming which files exist.
Beyond traversal, enforce authorization. A valid filename does not mean the requester is allowed to have it — check ownership or roles before streaming. Map opaque IDs to filenames through your database instead of exposing real paths in URLs.
Best Practices
- Use
res.download()to force a save andres.sendFile()to render inline — let the disposition match user intent rather than fighting it. - Always pass the completion callback in Express 4 and guard
next()withres.headersSent, since file streams can fail after the response has begun. - Prefer the
res.sendFile(name, { root })form for user-supplied names; its built-in containment beats hand-rolled checks. - Validate filenames against a strict allow-list and verify the resolved path stays inside the intended directory — never trust just one.
- Authorize every download against the current user; a syntactically safe path is not an authorized one.
- Override the download filename so users get human-friendly names while disk names stay stable, and encode non-ASCII names with
filename*=UTF-8''.