Research

We scanned 323 Next.js apps. In 21 of them, something deletes data without checking who is asking.

Sixty per cent of what we found was not in an API route at all. It was in server actions — files whose names promise a library and deliver a public endpoint.

There is a function in a real, public, actively maintained codebase that looks like this:

/**
 * Deletes expired status reports and their associated video files.
 * Called by the cleanup cron API route.
 */
export async function cleanupExpiredReports() {
  const now = new Date();
  const expiredReports = await db.statusReport.findMany({
    where: { expiresAt: { lt: now } },
  });
  ...
}

It lives four directories deep, under features/status-reports/Actions/. It takes no arguments. Its own comment tells you what calls it: the cron route. Every signal in that file says internal plumbing, nobody touches this but the scheduler.

The first line of the file is 'use server'.

Which means it is not internal. It is an HTTP endpoint. Next.js turns every export of a 'use server' file into a POST endpoint addressed by a generated action ID. Anyone who can discover that ID can invoke the function directly — which is why Next.js's own documentation says server actions must perform their own authorization checks. This one performs none, because the author never imagined anyone would be asking.

I do not think that developer was careless. I think they wrote a helper, put it somewhere sensible, and had no reason to look at line 1.

The thing worth being scared of is not that people write bad code. It is that the surface of a modern application is no longer visible by reading it.

What we measured

We took 400 public Next.js repositories from GitHub, sampled across four star bands so the result would not simply describe famous projects, and scanned every one with a static analyser that reads code without running it.

323 scanned cleanly — 30,277 ways in. Pages, API routes, server actions. Every entry point, what data each one touches, and whether anything establishes who the caller is before it runs. 117 of the 323 were workspaces, where the scanner followed its own hint to the application inside rather than giving up at the root.

147 of those applications can do something consequential: delete data, take payment, or change who has access. Those are the only ones that can fail this test — an application that only reads things cannot leak your database.

21 / 147

Twenty-one of the 147 applications able to fail had at least one consequential endpoint with no visible check on who is asking — 14.3%, 95% confidence interval 9.5–20.9. Counting findings the scanner itself flagged as uncertain, 24 applications, 16.3%. Measured over the 147 that can delete, charge or change access, not over all 323 scanned: an application that only reads things cannot fail this test, and including it would flatter the number. This is the second run of this study; the first said 22.7%, and the corrections below say why the number moved.

The part that should worry you

102 findings in total. Forty per cent were in route.ts files — ordinary API routes, where at least you know you have built a door.

Sixty per cent were server actions. Twenty-two of those 61 were not in a folder or file called actions at all, and seven sat in files with no hint of it anywhere in the name: services/healthcheck.ts, google-drive/index.ts, local/local.ts. Files whose names promise a library and deliver an endpoint.

One of them exports deleteGoogleDrive(config, input). It resolves a file path and calls client.files.delete(). It reads exactly like a storage adapter — the kind of function you would expect three layers of application code to sit in front of. It is directly reachable over HTTP.

You cannot catch this by reviewing a pull request, because the diff looks fine. You cannot catch it by reading the file, because the file looks like a library. You catch it by knowing that the first line changes the meaning of every export below it — and by checking, every time, on every file, forever.

Nobody does that. It is a job for a machine.

Ours is free and reads your code without running it — npx what-it-does. The rest of this page is what we got wrong measuring this, and what the number does not mean. Both are worth your time before you trust either.

What we got wrong

Measuring codebases we did not write broke our own scanner in five ways across two runs. It would be dishonest to publish the number without the list.

  • It discarded monorepos. Thirteen of the first forty-eight projects were workspaces, and the scanner recorded them as unreadable rather than following its own hint to the application inside. That is 27% of a sample, and the excluded projects were the substantial ones. Any figure produced before that fix was quietly biased toward toy apps.
  • It flagged the front door. Registration endpoints and one-time-code senders were reported as having no check on who is asking. True, and useless: those cannot sit behind a check, because they are what you use before you have an identity. 5.3% of findings, now suppressed.
  • It dropped its own uncertainty. Every finding carries a confidence — whether we could see the whole path, or something blocked the view. The report always showed it; the JSON output silently threw it away, so the CI integration built on that output could not tell a firm finding from a hedged one. We found that only because this study needed the field and it was not there.
  • It did not know a shared secret is a check on who is asking. A cron route that compares a header against CRON_SECRET, a webhook that calls receiver.verify(), a helper named hasCronSecret: all of these establish the caller, and the first run flagged them anyway. Five of thirty hand-checked findings in that run were this one class. Fixed in 0.4.5, with a control test that makes sure a route which merely reads the environment and checks nothing still fires.
  • Our precision claim was a spot check, and the spot check was too small. The first run hand-checked twelve findings and found twelve correct; the wider check of thirty found the class above. This run: thirty findings chosen by a fixed seed, each read by two independent automated reviewers with the code in front of them and instructed to disagree, and read by hand wherever they found a check or disagreed with each other. Twenty-seven stand. Two are false positives — a typed route wrapper that declares auth: admin in its schema, and a cron guard named in Portuguese, autorizaCron, which the 0.4.5 fix only knows in English. One could not be judged, because the extractor cut the handler off after its first line. Two of twenty-nine is 6.9%, down from five of thirty. Still a sample, not an audit.

Zero crashes and zero timeouts across 400 repositories, at least.

Corrections

25 September 2026.

Two statements were corrected. The article said the scanner stayed quiet on all 4,000 files of dub's monorepo; it now says it found nothing worth checking in any of dub's 801 ways in, which is what the report shows. It also said the pull request check makes no request to anyone; it now says what the check uses the network for: obtaining its runtime and dependencies, and interacting with the GitHub workflow and repository.

22 September 2026.

This article first ran on 11 August 2026 with a headline of 22.7% over 292 applications scanned and 141 able to fail. Four things about that were wrong, and the number above is not a refinement of it but a replacement.

  • The headline invited the wrong arithmetic. "One in five can delete your data" over "we scanned 292 apps" reads as a fifth of 292. The measured number was 32 of 141. The headline now carries the absolute count, which cannot be recomputed wrongly.
  • The first run's raw data was not kept. The per-repository results, the sample and the hand-check lived in a working directory that was discarded, so the 22.7% could not be recomputed, the false positives could not be removed from it, and the 25 confirmed findings could not be reported to their maintainers. This run's data is on disk and stays there; it is never published, because it is a list of names.
  • The first number included a false-positive class. The shared-secret guards described above. The correction was known within days of publishing and sat unreleased for six weeks. That is on us, not on the tool.
  • The split moved. The first run reported 60% of findings in route files and 40% in server actions; this run is the reverse. Most of that is the suppressed class, which lived almost entirely in route.ts cron handlers. The point of the section above — that the dangerous ones are the files that do not look like endpoints — got stronger, not weaker.

The two runs are different samples of the same population, drawn the same way. Every interval overlaps. We are not claiming the rate fell; we are claiming the second number is the one we can stand behind, and that the first was not.

What this is not

It is not a claim that those 21 applications are exploitable. We measured missing visible checks, not proven holes. A check can live in row-level security, in a middleware matcher we could not resolve, in an import we could not follow. Twenty-one of the 102 findings say exactly that, in the finding itself, and they are counted separately above.

It is not a claim about AI-written code. Nothing in a repository tells you who wrote it, and we are not going to invent that.

And no repository is named here, or anywhere. These are findings to check, not proven vulnerabilities, and publishing a list would be handing out a target sheet for a claim we have not made.

The band breakdown ran from 9% in the least-starred projects to 23% in the most. Do not read a trend into that: every confidence interval overlaps every other, and larger projects simply have more endpoints, so more chances to hold one.

Go and look at yours

The scanner is free, MIT licensed, and makes no network requests at all. It reads your code on your machine and writes a single HTML file next to it. No account, nothing uploaded, no telemetry — disconnect from the network and it works unchanged, which is the honest way to check that claim rather than taking our word for it.

npx what-it-does

It took ten and a half seconds on dub's open-source monorepo, and tells you every way into your application, what each one touches, and where nothing is checking who is asking. If it finds nothing, you have learned something real — it found nothing worth checking in any of dub's 801 ways in.

The method, the sampling frame and the harness are published on GitHub, so you can re-run this and disagree with us.

If you would rather not have to remember, the same scanner runs as a GitHub Action on every pull request and comments only when your application's behaviour actually changed. Also free, also MIT: the comparison runs inside your own workflow. The Action uses network access to obtain its runtime and dependencies and to interact with the GitHub workflow and repository, and no repository name, file, finding or identity is sent to us.

One thing I would ask. If it tells you something wrong about your own code, send it to rolf@eriksenlabs.com. That is what the accuracy work runs on, and this study is what happens when it finally gets some.