Key Takeaways
- Werkzeug ships security helpers for password hashing, filename sanitization, and safe path joining, plus a PIN-protected debugger.
- Its CVE history clusters around the debugger, multipart form parsing, the safe_join() helper, and cookie parsing.
- The 3.1.x branch is the only one guaranteed upstream security fixes, and 3.1.6 or later closes every CVE covered here.
- TuxCare’s ELS backports security fixes to Werkzeug 1.0.1, 2.2.3, and 2.3.8, delivered through pip.
A dependency scanner flags werkzeug as vulnerable, and nobody on the team remembers installing it, because Flask pulled it in on its own. Werkzeug is the toolkit underneath Flask that handles the request and response plumbing behind every route. It carries its own CVE history and its own defaults, separate from whatever Flask adds on top.
This article covers what Werkzeug protects against, where its verified CVEs sit, how to check your own exposure, and how to harden a Werkzeug application in production.
What Werkzeug Is and How It Fits Under Flask
Werkzeug is a WSGI utility library maintained by the Pallets project, the same group behind Flask, Jinja, and Click. It handles the low-level mechanics of an HTTP request and response: parsing headers, cookies, and multipart form bodies, matching URL routes, and providing a debugger for local development.
Flask is built on top of Werkzeug rather than replacing it. Flask supplies routing decorators, templates, and application structure, but its request and response objects are subclasses of Werkzeug’s, and much of the WSGI machinery underneath comes from Werkzeug too. Installing Flask installs Werkzeug as a dependency, which is why most teams run it without ever adding it to a requirements file on purpose.
In production, a WSGI server such as Gunicorn parses the raw HTTP and hands Flask a WSGI environment. From there, a flaw in Werkzeug’s request handling is reachable by anyone who can send your application a request. Routing happens before your view function runs, and most other request data gets parsed when something reads it. Form parsing, for instance, kicks in the moment code touches request.form or request.files.
Werkzeug’s Native Security Protections
Werkzeug ships several security helpers and safeguards that a Flask application can use without adding another dependency. Most of them only help if the application actually calls them.
werkzeug.security provides generate_password_hash() and check_password_hash(), which salt and hash passwords using a configurable algorithm instead of leaving that to application code. secure_filename() strips path separators and other unsafe characters from a user-supplied filename, so the name it returns is much safer to save under a directory the application controls. It only looks at the name, though. It doesn’t check what already exists on disk, and it can hand back an empty string, so the upload code still has to handle both.
safe_join() exists for the same reason at the directory level. It is meant to combine a trusted base directory with a user-supplied path and refuse to return anything that escapes that base, which is what send_from_directory() relies on to serve files safely. The interactive debugger adds a PIN requirement before it exposes a live Python console in a browser, meant to stop a stranger who stumbles onto a debug endpoint from getting code execution.
None of these protections is complete on its own. Each one reduces a specific risk, and two of them, safe_join() and the debugger PIN, have had documented gaps in exactly the risk they were built to close. That’s where a good chunk of the CVE history below comes from.
Werkzeug Security Risks and CVEs
As of September 2026, Werkzeug’s public CVE record runs to nine disclosures since 2023. Several of the more recent CVEs carry both a CVSS v3.1 and a CVSS v4.0 score, and trackers don’t always show both. Which one reads higher depends on the CVE. The safe_join() cluster scores higher under v4.0, while CVE-2024-49767 scores higher under v3.1. Seeing only one of them can make a finding look more or less urgent, but the underlying bug is identical.
| CVE | Severity (CVSS v3.1) | Affected versions | Fixed in |
| CVE-2024-34069 | 7.5 High | < 3.0.3 | 3.0.3 |
| CVE-2023-46136 | 7.5 High (NVD) / 5.7 Moderate (GitHub) | 2.0.0rc1–2.3.7, 3.0.0 | 2.3.8, 3.0.1 |
| CVE-2023-25577 | 7.5 High | < 2.2.3 | 2.2.3 |
| CVE-2024-49767 | 7.5 High | 2.0.0rc1–3.0.5 | 3.0.6 |
| CVE-2024-49766 | 5.3 Medium | <= 3.0.5 (Windows, Python < 3.11) | 3.0.6 |
| CVE-2025-66221 | 5.3 Medium | < 3.1.4 | 3.1.4 |
| CVE-2026-21860 | 5.3 Medium | < 3.1.5 | 3.1.5 |
| CVE-2026-27199 | 5.3 Medium | <= 3.1.5 | 3.1.6 |
| CVE-2023-23934 | 3.5 Low (NVD) / 2.6 Low (GitHub) | < 2.2.3 | 2.2.3 |
Debugger Remote Code Execution Exposure
The debugger issue, CVE-2024-34069, and its CSRF exploitation mechanics are covered in detail in TuxCare’s breakdown of what Werkzeug end of life means for Python applications. The short version is that a debugger is a code-execution risk even when it’s bound to localhost and sitting behind a PIN. The attack needs a domain and subdomain the attacker controls, a developer who interacts with them and enters the debugger PIN, and a guessed URL that triggers the debugger. Pull that off, and the attacker reaches a localhost-only debugger and runs code on the developer’s machine. It was fixed upstream in 3.0.3, and no upstream 2.x release carries the fix, since upstream 2.x support ended at 2.3.8. For teams pinned to 2.x, TuxCare’s ELS covers it with a backported fix for Werkzeug 2.3.8, and the CVE Tracker lists current coverage for every supported version.
Multipart Parsing Denial of Service
Three separate CVEs come out of the same code path. CVE-2023-25577 let a request carry an unlimited number of multipart parts, so parsing cost scaled with an attacker-controlled part count until CPU or memory ran out. It was fixed in 2.2.3, which is also the release that introduced the max_form_parts limit.
CVE-2023-46136 came at the same parser from a different angle, with a single crafted part. A file that starts with a CR or LF character and is followed by megabytes of data containing no further CR or LF characters forces the parser to keep rescanning a growing buffer for the boundary. NVD rates it 7.5 High and the GitHub advisory rates it 5.7 Moderate. Some trackers show a third number, 8.0 High, which comes from the CNA score in the official CVE record rather than the current GitHub advisory. The full breakdown of that split is in the Werkzeug end of life article.
CVE-2024-49767 closed a different gap in the same parser. A crafted form body could bypass max_form_memory_size entirely, so the memory limit an application set never actually applied. NVD scores this one 7.5 High (CVSS v3.1), and the GitHub advisory scores it 6.9 Moderate (CVSS v4.0).
A parser limit only helps if it’s enforced early enough to stop attacker-controlled memory use, and the right limit depends on the attack. max_form_parts caps floods of tiny parts like the ones behind CVE-2023-25577. max_content_length and a body-size cap at the reverse proxy limit total request size, and neither of those depends on the multipart parser catching the problem internally.
Windows Path Flaws in safe_join
safe_join() has been patched four times in about sixteen months, every fix Windows-specific and every one about how the function decides a path is safe.
CVE-2024-49766 is the oldest of the four. On Python versions before 3.11 running on Windows, os.path.isabs() does not recognize UNC paths such as //server/share as absolute, and safe_join() relies on that check, so a UNC path could slip through. It is fixed in 3.0.6.
The other three involve Windows reserved device names such as CON, AUX, and NUL, which Windows treats as present and readable in every directory, no matter what the directory actually contains. CVE-2025-66221 found that safe_join() accepted those names outright, and 3.1.4 fixed it. CVE-2026-21860 found that the first fix missed compound extensions such as CON.txt.html, names with trailing spaces, and a few extra special names, which 3.1.5 cleaned up. Then CVE-2026-27199 showed that a multi-segment path such as example/NUL still got through, and 3.1.6 closed that.
In each of those three device-name cases, send_from_directory() opens the device successfully and then hangs indefinitely on read, tying up one worker per request until the process runs out of workers to serve real traffic.
Cookie Parsing Vulnerability
CVE-2023-23934 is the quietest entry on this list. NVD scores it 3.5 Low, and GitHub, the CNA that assigned the CVE, scores it 2.6 Low. Some browsers accept a cookie shaped like =value with no name, and Werkzeug versions before 2.2.3 parsed a cookie such as =__Host-test=bad as a legitimate __Host-test cookie.
So a malicious or compromised subdomain could plant that malformed cookie and have Werkzeug read an attacker-controlled value under the protected __Host-test name, which defeats the whole point of the __Host- prefix. It is fixed in 2.2.3.
Either score is low because the attack needs both a vulnerable browser and a malicious or compromised adjacent subdomain. The consequence is another story if the forged cookie carries a session token or a CSRF token. CVSS scores the mechanism, and it has no idea what a given application keeps in that cookie.
How to Check Your Werkzeug Version for Vulnerabilities
Checking exposure takes about two minutes per service. The goal is to answer one question: is the Werkzeug version actually running in production on a branch that still receives fixes?
Start with what is installed, not what a requirements file claims.
pip show werkzeug
Run this inside the container or virtual environment that’s actually serving traffic. Anything older than 3.1 sits outside the only branch guaranteed upstream security fixes. Pallets may backport a fix to the previous feature branch on request, but that isn’t promised. A 3.1.x release below 3.1.6 is on the right branch but still missing at least one fix, and the current release at the time of writing is 3.1.8. Either way, the “Affected versions” and “Fixed in” columns in the table above show whether each CVE applies and which release carries its upstream fix, and the Windows-only safe_join() rows only matter on Windows hosts.
Version numbers alone don’t settle which CVEs apply to a given Python version and OS, so run an audit tool in that same environment.
pip install pip-audit
pip-audit
With no arguments, pip-audit checks the packages installed in the active environment. If a requirements file is what needs auditing instead, pip-audit -r requirements.txt handles that. pip-audit is maintained under the Python Packaging Authority and pulls vulnerability data from PyPI by default, with OSV available as an alternative source through -s osv. Some teams also run a second scanner, such as Safety, for another view of the advisory data.
Updating the package on disk doesn’t replace Werkzeug code a running worker has already imported. Long-running Gunicorn or uWSGI workers keep the old code loaded until they restart, so restart workers after any Werkzeug change and confirm the new version actually loaded rather than just installed.
For Werkzeug security news specifically, TuxCare’s CVE Tracker lists ELS-patched vulnerabilities as they are confirmed in older versions that no longer get upstream fixes.
Best Practices for Securing Werkzeug Applications
Some of Werkzeug’s risk comes from configuration, not version, and these habits apply on every release.
Start with the debugger. Set debug=False and never enable the interactive debugger in production, even bound to 127.0.0.1, since CVE-2024-34069 showed that localhost isn’t the safe boundary it looks like. Run behind a production WSGI server such as Gunicorn, uWSGI, or Waitress, because run_simple() and flask run are development tools that were never meant to face production traffic.
Next, set request limits on purpose. max_content_length, max_form_memory_size, and max_form_parts should match what the application really accepts, and body size should be capped at the reverse proxy too, so oversized or overly complex multipart requests get turned away before they can eat serious memory or CPU. Explicit values matter most on pre-3.1 releases, where max_form_memory_size defaulted to unlimited. Werkzeug 3.1 changed that default to 500 kB.
Serve static files from a dedicated web server such as nginx where possible, which keeps send_from_directory() and safe_join() out of that request path entirely. And run the application process as a non-root user, so a path bug that does get through has less to reach.
Configuration closes gaps a version upgrade doesn’t reach on its own, and the reverse holds too. None of these settings substitute for running a current 3.1.x release, and every CVE in the table above is fixed in 3.1.6 and later.
How TuxCare ELS Keeps Werkzeug Secure Past End of Life
Upgrading isn’t always possible on a given timeline. A vendor-supplied build, a certified deployment, or a Python runtime nobody has budgeted to move can all keep an application pinned to an old Werkzeug release long after the CVEs in that table start applying to it.
TuxCare’s Endless Lifecycle Support for Werkzeug continues providing security fixes for versions 1.0.1, 2.2.3, and 2.3.8, delivered as signed packages through pip. Teams can stay on the version they already run while covered security fixes keep arriving, so a new CVE doesn’t automatically turn into an emergency migration, and the real migration can happen on their own schedule.
FAQs about Werkzeug Security
How do I check if my Werkzeug version has known vulnerabilities?
Run pip show werkzeug inside the deployed environment to confirm the installed version, then run pip-audit in that same environment to check its packages against known advisories. For the CVEs covered here, 3.1.6 is the first upstream release with every fix, though 3.1.8 is the current release at the time of writing and the better target.
Does upgrading Flask also fix Werkzeug CVEs?
Not necessarily. Flask sets a minimum Werkzeug version rather than an upper bound, and pip install –upgrade flask leaves an already-installed Werkzeug alone as long as it still meets that minimum. Upgrading Flask is worth doing, but checking the resolved Werkzeug version directly is the only way to know for certain.
What is the safest way to run Werkzeug in production?
Run it behind a dedicated WSGI server such as Gunicorn, uWSGI, or Waitress, with debug=False, explicit max_content_length, max_form_memory_size, and max_form_parts limits, and static files served by a separate web server rather than send_from_directory().
Why do some Werkzeug CVEs show different severity scores in different places?
Several recent Werkzeug CVEs carry both a CVSS v3.1 and a CVSS v4.0 score, and the two frameworks weigh the same details differently. CVE-2026-27199, one of the safe_join() fixes, carries a 5.3 under CVSS v3.1 in NVD and a 6.3 under CVSS v4.0 in the GitHub advisory, so the number a tracker shows depends on which source and standard it defaults to. The vulnerability behaves the same either way.

