Skip to content

AI Code Tutorial · No. 04

Fewer moving parts, fewer emergencies.

Why we build web apps with Flask, Jinja, compiled Tailwind and vanilla JavaScript rather than React or Next.js. The week of 21 October 2026 made the security case better than we could; the performance case was already there. With a prompt to audit your own stack.

14 min read All levels
SecurityFlaskNext.jsPerformance
Read the case

The short version.

Every line of code you run, and every package you install, is something that can break or be broken into. A Flask app that renders HTML on the server, styles it with one compiled CSS file and adds a few small JavaScript modules runs on about ten Python packages and ships no framework to the browser. A typical Next.js app installs several hundred npm packages and runs a JavaScript framework on both the server and the client.

For years that difference was mostly about speed and simplicity. In 2026 it became a security question. npm was hit by a self-spreading worm almost every month. Server-side React accumulated a critical remote-code-execution flaw and a string of denial-of-service bugs. And in a single week in October, both the PHP and the Node.js runtimes shipped emergency fixes for flaws that can run an attacker’s code, while Python’s advisories stayed in a handful of optional libraries.

None of this makes Python magically safe, and we say where it is not. It does make a small, boring, server-rendered stack the one we would rather defend, and the one our coding agents get right.


The context

The week of 21 October.

Three runtimes, one month. The dates below come from the projects’ own advisories and the security firms that analysed them.

Fig. 1 — October 2026, by date
DateWhatDetailWhere it lives
1 Oct PYTHONCPython security releases for 3.10 to 3.14 Fixes in the standard library, including bounded zipfile decompression. A routine upgrade. Routine
16 Oct PYTHONPillow 13.0.0 fixes three image-parsing flaws EPS through Ghostscript, TIFF writes to in-memory buffers, and downscaled lossless JPEGs. Only reachable if you process untrusted images; the EPS one also needs Ghostscript installed and an attacker-controlled file name. Optional library
17 Oct PHPFirst exploitation attempts against PHP2Shell AWS reported attempts four days before any patch existed. Runtime
21 Oct PHPPHP2Shell: CVE-2026-109618 and CVE-2026-109619, CVSS 9.2 Unauthenticated remote code execution in the PHP runtime, triggered during request handling before application code runs. Over 22 million exposed instances by one count. Fixed in 8.2.35, 8.3.36, 8.4.27 and 8.5.12. Runtime
21 Oct NODE.JSTen CVEs across 22.x, 24.x and 26.x Including CVE-2026-111497, an out-of-bounds write in the Brotli decoder bundled with Node that can lead to remote code execution, and three high-severity TLS and DNS flaws. Node 20 and older get no fix. Runtime
22 Oct PYTHONBrotli 1.2.1 and brotlicffi 1.2.1.0 The same Brotli bug, in the optional Python packages some HTTP clients use when present. Advisory scanners did not flag it at first, so check by hand. Optional library
23 Oct PHPA second, lower-risk PHP advisory An unusual configuration only; PHP recommends disabling session upload progress until the next release. Runtime

And the same day, the platform.

On 21 October Railway also disclosed that customers’ environment variables may have been obtained by an unauthorised party. That one hit every stack equally; we covered it in No. 01 and No. 02. It is a reminder that the framework is only one layer, and why every other layer should be as small as you can make it.


Where the flaws landed

Runtime or library makes all the difference.

A flaw in the runtime reaches every app on it, whatever the app does. A flaw in a library reaches only the apps that install it, and often only the ones that use a specific feature.

PHP

The runtime itself.

PHP2Shell sits in how PHP sets up and tears down each request. On the upload path it fires before your script starts, so neither your framework nor your login page can stop it. Mod_php, PHP-FPM, CGI, FrankenPHP and LiteSpeed are all in scope. The only fix is a new PHP build.

NODE.JS

The runtime, through a bundled library.

The Brotli decoder ships inside Node. Any code that decompresses Brotli data it did not create with node:zlib is exposed, and that can include HTTP clients that decompress responses for you. You cannot uninstall it; you upgrade Node, on supported lines only.

PYTHON

Optional libraries.

The same Brotli bug reached Python only through the Brotli and brotlicffi packages, used by some HTTP clients when installed. Pillow matters if you handle uploaded images. A plain Flask app installs neither. One command tells you, one upgrade fixes it.

Is my Flask app affected?

For October, three questions cover it. Is Brotli or brotlicffi installed? Is Pillow installed, and do you open images that users upload? Is Ghostscript on the server? If the answer to all three is no, the month’s Python advisories do not reach you, beyond the routine CPython update.

Flask itself had one advisory this year, CVE-2026-27205: in some cases a response was not marked as varying by cookie, so a shared cache could serve one user’s page to another. It is real, it is fixed, and it is a different order of problem from code execution in the runtime.

# is any of this month's code installed?
pip show brotli brotlicffi pillow 2>/dev/null \
  | grep -E '^(Name|Version)'
command -v gs && echo "Ghostscript present"

# fix what you have
pip install -U "brotli>=1.2.1" \
  "brotlicffi>=1.2.1.0" "pillow>=13.0.0"

# scanners lagged on Brotli: check by hand
pip-audit

The supply chain

Every package is a maintainer account.

In 2026 the most effective attack on web apps was not a clever exploit. It was stealing one maintainer’s token and publishing a poisoned version that ran on install, then using the developer credentials it found to poison the next package.

WhenCampaignWhat it did
Spring CanisterSprawl A postinstall hook steals npm tokens and secrets, then republishes the victim’s own packages with the payload.
May Mini Shai-Hulud Started at TanStack: over 170 npm packages, and two PyPI packages including an AI SDK. Credential theft, self-propagation, a destructive branch.
June Miasma and IronWorm Code execution through a binding.gyp file, with no install script in package.json. 57 packages in under two hours. IronWorm hunted for AI-tool and cloud credentials.
August ChainDrop A popular caching library’s maintainer compromised; hundreds of co-owned packages backdoored, totalling over a billion monthly downloads.
Fig. 2 — Packages installed for the same dashboard
Dependency count, Flask versus Next.js For the same SaaS dashboard in our benchmark, the Flask version installs about ten Python packages and no npm packages. The Next.js version installs 847 npm packages. Each one is a maintainer account an attacker can target. Flask ~10 Python packages · 0 npm Next.js 847 npm packages Bar length to scale. Each package is a publisher whose token can be stolen, and many can run code when installed.

PyPI is not immune, and we will not pretend it is.

The May campaign crossed over to PyPI, and a malicious release of a popular Python SDK was live for about three hours. Python packages built from source can run code at install time too. The difference is exposure: ten publishers you can name, against hundreds you have never heard of, several levels down your dependency tree.

Whatever you use: pin, hash, wait.

Pin exact versions with hashes, install wheels rather than source builds, and let new releases sit a few days before your builds adopt them. Many poisoned versions in 2026 were found and pulled within hours, so a soak period of a few days keeps them out of your builds.


Server-side React

A parser on the internet.

React Server Components and Server Functions let the browser call server code directly. To do that, the server accepts a custom serialised format from anyone on the internet and decodes it before your code runs. That decoder has had a hard year.

WhenWhatDetailSeverity
Mar 2025 Next.js middleware bypass CVE-2025-29927. A single request header could skip middleware entirely, including the authentication checks many apps put there. Critical
Dec 2025 React Server Components RCE CVE-2025-55182, CVSS 10.0. A crafted request to a Server Function endpoint achieved remote code execution, even in some apps that defined no Server Functions. Critical
Jan 2026 Denial of service CVE-2026-23864, CVSS 7.5. Memory exhaustion or CPU burn from a crafted payload. High
Apr 2026 Denial of service, again CVE-2026-23869. Patched; the React versions shipped for it turned out to be still vulnerable to the next one. High
May 2026 Twelve at once One React Server Components flaw (CVE-2026-23870, a single POST freezes the server) and eleven in Next.js: middleware bypass, XSS, SSRF, cache poisoning and denial of service. High

What a Flask form receives instead.

A plain HTML form posts URL-encoded or multipart data, formats that have been parsed on the open web for thirty years. Werkzeug’s parser is not flawless, it had a multipart denial-of-service fix in 2024, but it is small, old and heavily exercised. There is no protocol that turns a request into a call to an arbitrary server function.

Explicit routes, explicit checks.

In Flask every URL the app answers is a decorated function you can grep for, and authentication is a decorator on that function, not a middleware layer a header might skip. An agent, or a reviewer, can list the whole attack surface in a minute.


The performance case

Nothing to download, parse or hydrate.

The same SaaS dashboard, built twice on the same Postgres schema. The numbers are from our own benchmark on Flask Vibe, with the method published; read the caveat below them.

MetricFlaskNext.js 14
Largest Contentful Paint 905 ms 1.66 s
Server response (TTFB) 30 ms 100 ms
JavaScript shipped 0 KB 95 KB, split
Network requests 4 28
Hydration None About 200 ms
Cold start, idle app About 0.8 s About 2.4 s
Build time 0 s About 47 s
Lines of code About 420 About 4,200
Lighthouse performance 100 100

Why the gap exists.

The Flask page arrives as finished HTML. The browser paints it and is done. The React page arrives, then downloads its JavaScript, parses it, and re-runs the components to attach behaviour, which is hydration. Server rendering in Next.js narrows the gap for first paint, not for the work that comes after it.

The honest caveat.

Our benchmark, our preference. Hosting differed: Flask on a paid Railway plan, Next.js on Vercel Free, which inflates the cold-start and server-time gaps. Both score 100 on Lighthouse. Treat the figures as an order of magnitude and measure your own pages.


What we actually run

Five boring parts.

Each one is chosen as much for what it leaves out as for what it does.

Flask and Jinja

Routes are functions, pages are templates. Jinja escapes every value by default, so a forgotten filter is the exception you can grep for, not the rule.

Direct SQL

psycopg2 with parameterised queries and no ORM. What the agent wrote is exactly what runs against the database, and it can be read in review.

Compiled Tailwind

Built once into a single CSS file, with the npm package or the standalone binary. Nothing from npm reaches the server or the browser. Never the CDN build.

Vanilla JavaScript

Small ES modules the browser loads natively, only on pages that need them. No bundler, no framework runtime, no hydration, and DevTools shows the real source.

Gunicorn

A process manager that has served Python web apps for fifteen years. Configured once, rarely touched.

Why it suits coding agents.

An agent writes code faster than anyone can review it, so the stack should make review cheap. A Flask route, a Jinja template and a SQL query can be read top to bottom with nothing hidden in between: no generated client, no build output, no framework deciding at runtime which code is server and which is client. When the agent gets something wrong, you can see it.

Fewer layers also means fewer places for a security mistake to hide. The checks that matter, CSRF on every form, secure session cookies, parameterised SQL, escaped output, sit in a few lines you can verify.

# the whole feature: route, query, template
@app.route('/notes', methods=['GET', 'POST'])
@login_required
def notes():
    if request.method == 'POST':
        body = request.form.get('body', '').strip()
        if body:
            with get_db() as cur:
                cur.execute(
                    'INSERT INTO notes (user_id, body) '
                    'VALUES (%s, %s)',
                    (current_user.id, body))
        return redirect(url_for('notes'))
    with get_db() as cur:
        cur.execute('SELECT body FROM notes '
                    'WHERE user_id = %s', (current_user.id,))
        rows = cur.fetchall()
    return render_template('notes.html', rows=rows)

# notes.html: escaped by default, CSRF token included
<form method="post">
  <input type="hidden" name="csrf_token"
         value="{{ csrf_token() }}">
  <textarea name="body"></textarea>
</form>
{% for r in rows %}<p>{{ r.body }}</p>{% endfor %}

Fair is fair

When React is the right call.

State-heavy interfaces

Editors, design tools, trading screens: hundreds of interdependent components changing many times a second.

Real-time collaboration

Several people editing the same thing, with optimistic updates and conflict handling in the browser.

Large teams fluent in React

Enforced component patterns and a huge library ecosystem pay for themselves across dozens of developers.

Shared code with React Native

One component model across web and mobile can be worth the weight.

If you stay on Next.js, shrink what is exposed.

Stay on a patched minor (15.5.18, 16.2.6 or later at the time of writing) and keep the react-server-dom-* packages on fixed versions. Turn off Server Functions you do not use, do not rely on middleware alone for authentication, run Node on a supported line, and put a soak period in front of new npm releases. A browser-only React app avoids the server-side class of flaws entirely.


Keeping it that way

A boring patch routine.

A small stack only stays small and safe with a habit around it. This is ours.

WhenHabitHowDone when
Always Pin exact versions with hashes pip-compile --generate-hashes, then pip install --require-hashes Lock file with hashes
Always Prefer wheels over source builds pip install --only-binary :all: where possible No setup.py runs
Always Let new releases soak Do not adopt a release in its first few days, unless it is a security fix you have read No same-day upgrades
Weekly Audit installed packages pip-audit, plus a manual check for advisories the databases have not mapped yet Nothing open
Weekly Prune what you no longer use pip list --not-required, then remove leftovers Shorter list
On news Check exposure, not headlines pip show for the named package, command -v for system tools like Ghostscript Known yes or no
On news Patch, rebuild, redeploy Upgrade, rebuild the image so vendored copies update, redeploy the existing build Fixed version live
Monthly Upgrade Python on its line Move to the latest patch release of your CPython series Current patch release

Not on PHP? Do not let PHP scanners reach you.

After PHP2Shell, scanners hit every public site looking for PHP and WordPress paths, Flask apps included. No. 03 shows how to drop them at Cloudflare before they cost you a request.

The audit prompt

Ask your agent what you are really running.

Read-only from start to finish. It inventories your runtimes and dependencies, checks them against the October 2026 advisories, and tells you how much surface you could remove. Works for Python, Node.js and PHP projects alike.

You are going to audit the attack surface of this web application. This is read-only: do not install, upgrade, remove, commit or deploy anything. Report findings and wait.

Project: [path or repository]. Production runs on: [platform, or "find out"].

HARD RULES
- Read-only. No installs, no upgrades, no lock file changes, no commits, no deploys.
- Never print secrets. If a file or variable contains one, refer to it by name only.
- Cite the file and line for every finding.
- If you cannot verify something, say so instead of guessing.

STEP 1 - RUNTIMES
1. Identify every runtime the app uses in production and in its build: Python, Node.js, PHP, and their exact versions (Dockerfile, runtime.txt, .python-version, .nvmrc, engines field, composer.json, platform config).
2. Compare them with the October 2026 fixed versions: PHP 8.2.35, 8.3.36, 8.4.27 or 8.5.12 (PHP2Shell, CVE-2026-109618 and CVE-2026-109619); Node.js 22.23.4, 24.22.1 or 26.12.1 (CVE-2026-111497 and nine others; Node 20 and older are unsupported); the latest patch release of the CPython series in use.

STEP 2 - DEPENDENCIES
3. Count direct and total installed dependencies per ecosystem, from the lock files (requirements with hashes, poetry.lock, package-lock.json, pnpm-lock.yaml, composer.lock).
4. Say which dependencies reach production (server or browser) and which are build-only.
5. List packages that can run code at install time: npm install, preinstall, postinstall or binding.gyp; Python source distributions without wheels.
6. Check whether versions are pinned exactly, whether hashes are verified, and whether anything installs the newest release automatically.

STEP 3 - KNOWN EXPOSURE
7. Python: is brotli below 1.2.1, brotlicffi below 1.2.1.0, or pillow below 13.0.0 installed? Does the app open user-supplied images? Is Ghostscript present in the production image?
8. React or Next.js: versions of next and react-server-dom-*; are Server Components or Server Functions in use; is authentication enforced only in middleware?
9. Run the ecosystem's audit tool (pip-audit, npm audit, composer audit) and note that advisory databases may lag behind the news.

STEP 4 - SURFACE YOU COULD REMOVE
10. List dependencies that are unused, duplicated, or used for something a few lines of code could do.
11. For each page, say whether it needs client-side JavaScript at all, and how much is shipped today.
12. List every public route or endpoint and where its authentication check lives.

REPORT
13. A short summary first: anything urgent, with the fix and the exact version.
14. Then a table per step: finding, file and line, risk, suggested change.
15. End with the three changes that would remove the most attack surface for the least work. Do not make any of them; wait for me.

Works with any agent that can read files and run shell commands: Claude Code, Codex, Cursor and the like. Run it with command approval switched on.


Questions

The short answers.

Is Python safer than JavaScript?

No language is safe on its own, and Python had advisories this month too. The difference in October 2026 was where the flaws landed. In PHP and Node.js they were in the runtime itself, which every app runs. In Python they were in optional libraries, Brotli and Pillow, that many apps never install and that are easy to check for and upgrade. The bigger factor is the size of what you ship: a Flask app pulls in about ten packages, a typical Next.js app several hundred.

PyPI had a supply-chain attack this year too. Does that not cancel the argument?

It weakens any claim that Python is immune, so we do not make one. The May 2026 campaign that hit TanStack on npm also poisoned two PyPI packages. The argument is about exposure, not immunity: every package you install is a maintainer account that can be stolen, and a stack with ten packages offers far fewer of those than one with eight hundred. Pin with hashes and wait a few days before adopting new releases, whatever you use.

Do you not need npm for Tailwind?

Only on the machine that builds the CSS, and not even there if you use the standalone Tailwind CLI, a single binary with no node_modules. Either way, nothing from npm runs on the server or in the browser: what ships is one compiled CSS file.

Is React itself insecure?

Client-side React escapes output by default, much like Jinja. The run of 2025 and 2026 advisories concerns the server side: React Server Components and Server Functions, which accept a custom serialised protocol from the internet and parse it before your code runs. If you use React only in the browser, those specific flaws do not apply to you.

Are the performance numbers fair?

They come from our own benchmark on Flask Vibe, with the method published, and they favour the setup we prefer. Hosting differed: Flask ran on a paid Railway plan, Next.js on Vercel Free. Treat them as an order of magnitude, then measure your own pages. The structural point holds regardless: a page that ships no JavaScript has nothing to download, parse or hydrate.

When would you choose Next.js?

For a highly interactive application with a lot of client state, real-time collaboration, or a large team already fluent in React that benefits from enforced component patterns. For the content sites, dashboards, CRUD tools and SaaS back offices most of us build, it is more machinery than the job needs.

Can an AI agent build a good vanilla JS front end?

Yes, and in our experience more reliably than a framework one, because there is less hidden behaviour to get wrong and the result can be read without running a build. Small ES modules with clear boundaries are exactly the kind of code agents write and humans can review.