When users need to download a file — an invoice PDF, a CSV report, an uploaded image — Flask already provides send_file and send_from_directory to do it cleanly and safely. Used correctly, these two functions force the browser to download instead of opening the file, and block path traversal attacks that slip ../ into the path to read files outside the allowed folder. This post applies when files live in a fixed folder such as uploads or exports.

The Problem

A common hand-rolled approach is to join the path yourself and return the response yourself. The code below runs, but it has two bugs: the filename taken from the URL is joined straight into the path, and the whole file is read into RAM before being returned.

from flask import Flask, Response
app = Flask(__name__)
@app.route("/download/<path:filename>")
def download(filename):
path = f"uploads/{filename}" # nguy hiểm: ../ đọc trộm file khác
with open(path, "rb") as f:
return Response(f.read(), mimetype="application/octet-stream")

Just calling /download/../../config.py lets an attacker escape the uploads folder and read other files on the server. On top of that, because no download filename is set, the browser may open the file directly in a tab instead of downloading it.

The Solution

If the filename comes from the URL, use send_from_directory: it uses Werkzeug’s safe_join internally, so a path escaping the allowed folder returns 404 instead of reading the file.

from flask import Flask, send_from_directory
app = Flask(__name__)
@app.route("/download/<path:filename>")
def download(filename):
return send_from_directory(
"uploads",
filename,
as_attachment=True,
download_name=filename,
)

as_attachment=True forces the browser to download the file, while download_name sets the exact filename the user will see (since Flask 2.2, the old attachment_filename parameter has been renamed to download_name).

For a file generated at runtime — for example a CSV report exported from the database — use send_file with an in-memory file object:

import io
from flask import Flask, send_file
app = Flask(__name__)
@app.route("/export")
def export():
data = "name,total\nAn,120000\nBinh,85000\n"
buffer = io.BytesIO(data.encode("utf-8"))
buffer.seek(0)
return send_file(
buffer,
mimetype="text/csv",
as_attachment=True,
download_name="bao-cao.csv",
)

The selection rule is simple: a file that already exists in a folder goes through send_from_directory, a file created at runtime goes through send_file. Both stream data instead of reading everything into RAM.

Apply It to Your Project

In a real project, do not leave the download folder inside static with free access — anyone who guesses a filename can download it. Put files in a separate folder outside static, check ownership before calling send_from_directory, and log each download for reconciliation. For on-demand exports, name files with the date, such as bao-cao-2026-10-27.csv, so older files are never overwritten.

Related Posts

FAQ

What is the difference between send_file and send_from_directory?

send_file takes a specific file (an absolute path or a file object). send_from_directory additionally takes a base folder and a filename, joins the path safely, and blocks path traversal. When the filename comes from the user, always use send_from_directory.

Do I still need to check for ../ in the filename myself?

No — if you use send_from_directory, Werkzeug already blocks it. You should still add extra checks: only allow the file extensions you support, and check access permission before serving the file, because blocking traversal is not the same as authorization.

Does send_file read a very large file fully into RAM?

No — if you pass a path or a file object to send_file, Flask and Werkzeug stream it in chunks instead of reading it all at once. What you must avoid is calling .read() on the whole file yourself and putting the bytes into the response, as in the broken example at the top.

Will a Vietnamese filename with diacritics download correctly?

Yes. download_name is encoded by Werkzeug following RFC 5987, so modern browsers display accented names correctly. You do not need to set the Content-Disposition header by hand.

💬 Have you tried this in your project? If you hit an error or know a cleaner way, leave a comment below — I read them all and will add the best ideas to this post.

References