Skip to content
Back to field notes
ExposureJune 6, 20266 min read

Public backup files in the webroot: .sql, .zip, .bak reachable without auth

A database dump or project archive left in your served directory is downloadable by anyone who guesses the filename. This note shows how to check your own public URL before you share it.

ExposureBackupsDatabase dumps

A guessable backup filename is a full data download

A single backup.sql sitting in your served directory hands over every user record at once, to anyone who requests it. There is no login, no rate limit, no audit trail. The visitor types the filename, gets a 200, and downloads your database. This note is about finding that file before someone else does.

The risk is not that a file exists. The risk is that a file containing your data sits inside the directory your web server hands out over HTTP, with no auth in front of it. If the URL https://yourapp.com/backup.sql returns the file, then every row in that dump is now public. A database export is the worst case here because one file can contain every user, every email, every password hash, every token you stored.

The filenames are predictable, which is what makes this a real risk and not a theoretical one. Nobody has to guess a random string. They request a short list of names that humans and tooling actually use:

Note the distinction between observable and possible. What is observable is the HTTP response: you request the name and either get a file (a 200 with bytes) or you do not (a 404, a 403, or an empty body). What is only possible is what an attacker does next with the contents. You do not need to prove the second part. A 200 that returns an archive or a dump is the launch blocker on its own.

  • Database dumps: backup.sql, db.sql, dump.sql, database.sql, and their compressed forms dump.sql.gz, backup.sql.gz.
  • Project archives: backup.zip, site.tar.gz, app.tar.gz, www.zip.
  • Editor and ad-hoc backups: app.bak, config.bak, index.php.bak, .env.bak.

Why AI-built apps end up with one

This is almost never written into the application. It is a deploy-time artifact that lands in the wrong place and then gets forgotten.

The common path is a manual export during setup or debugging. Someone runs a database dump to back up before a risky change, or zips the whole project to move it between machines, and writes the output into the project folder because that is the working directory they are already in. That folder is also the served directory. The file works its way into production on the next deploy.

AI-assisted workflows make this easier to do without noticing. A coding agent or a quick prompt that says "back up the database before migrating" will happily write dump.sql into the current directory, which is the repo root, which is what gets deployed. A build step that copies the project into the image can sweep up a stray archive that a developer left behind weeks earlier. The app keeps working the whole time, so nothing surfaces the file. Fast deploy means the public URL exists before anyone audits what is actually being served.

  • The file is not referenced by any route or link, so it is invisible from inside the app.
  • It was created once, by hand, so it is not in a code review diff anyone looked at.
  • The deploy copied everything in the directory, and "everything" quietly included the dump.

Check the public webroot for backup files

You can confirm this on your own app without any attack. Request the common backup filenames against your own production URL and watch the status code. Do this only against a domain you own.

From the browser: open your production site, then in the address bar request each name directly, for example https://yourapp.com/backup.sql, then /db.sql, then /backup.zip, then /dump.sql.gz, then /app.bak. Use DevTools, Network tab, to read the real status code rather than trusting what the page appears to show.

From the command line: a request like a HEAD or GET to https://yourapp.com/backup.sql lets you read the status code and content type without downloading the whole file. Walk the list above, one name at a time.

One trap to call out: turning off directory listing does not help here. Directory listing being disabled only stops someone from browsing a folder index. It does nothing if the filename is guessable, because the request goes straight to the file, not to the folder. An app with listing off and backup.sql present is still fully exposed.

  • Suspicious result: a 200 that returns a file, especially with a content type like application/sql, application/zip, application/gzip, or application/octet-stream, or a content length that matches a real export. That is a reachable backup and a launch blocker.
  • Safer result: a 404 (not found) or a 403 (forbidden) for every name on the list. A redirect to your app's normal not-found page is also fine, as long as no file body comes back.
  • Repeat for every name. The check is per filename. A 404 on backup.sql tells you nothing about dump.sql.gz or site.tar.gz, so walk the whole list.

Move backups out of the served directory

The fix is to make sure nothing reachable over HTTP contains your data, and to keep it that way.

What this does not cover: a deny rule for known extensions stops the named patterns, not a dump someone saved as data.txt or export. The durable fix is the location rule, where backups live outside the webroot, so the extension never matters.

  • Never write backups into the served directory. Direct database dumps and project archives to a path outside the webroot, or to a private storage bucket that is not publicly readable. The served directory should only contain the files you intend to serve.
  • Delete stray artifacts you already have. Search your deploy output and your repo for the extensions that matter: .sql, .sql.gz, .zip, .tar.gz, .bak, .dump. Remove any that are inside what gets deployed, and add those patterns to your ignore file and your build's exclude list so they cannot return.
  • Add a deny rule at the server or platform layer for archive and dump extensions, so even a future stray file returns a 403 instead of its contents. This is a backstop, not the primary control. The primary control is not putting the file there.
  • Rotate credentials if a reachable dump contained them. If a backup that was downloadable held database passwords, API keys, tokens, or session secrets, treat those as exposed and rotate them. You cannot know whether the file was fetched while it was public, so assume it was.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check probes the common backup filename patterns on your public surface: .sql, .sql.gz, .zip, .bak, .tar.gz and similar. If one of those names returns a 200 with a file, the scan flags it as a launch blocker with severity and fix direction, because that is exactly the thing an outside visitor can reach. It checks the public URL, the same way a stranger would.

What it does not do is enumerate every possible filename. It checks a known list of common patterns, not the full space of names someone might have used. A dump saved under an unusual name, or an archive that is not one of the probed extensions, will not be caught by a pattern check.

Next step: do a manual sweep of your actual deploy directory for the extensions .sql, .sql.gz, .zip, .tar.gz, .bak, and .dump, then move anything that holds data outside the webroot. Run a focused Launch Check against your production URL before you share it, so a reachable dump shows up in your scan and not in someone else's download.

A clean Launch Check on backup patterns does not prove your deploy directory is clean. The scan confirms the common names are not reachable from outside; it cannot see a file you named something unexpected, and it does not inspect your server filesystem. Treat any flagged file as a launch blocker, and treat a clean result as one signal, not a full inventory.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.