# Exposed .git folder and .env file: check your site before someone else does

Published: 2026-11-12 · By HackerGPT Team
Canonical: https://hackergpt.org/blog/exposed-git-folder-env-file/

> How to check whether your site serves its .git folder or .env file, run first-hand with curl: why a 200 status proves nothing on a single-page app, what a real exposure returns, what to rotate if you find one, and how to stop it shipping again.

**TL;DR:** Request `https://your-app.example/.git/HEAD` and `https://your-app.example/.env` and read the body, not the status code. A real exposure returns `ref: refs/heads/main` or `KEY=value` lines; a single-page app returns `200` with its own HTML for every path, which is not a leak. If either file is really served, block it, rotate every secret it or the git history holds, then change the deploy so only build output reaches the web root.

This is for the owner of a small site or SaaS who deploys by copying a project folder, pulling a repository on the server, or running a hand-rolled Node or Python server. In ten minutes you will know whether your site hands out its repository or its secrets, and what to do if it does. Only test sites you own or have written permission to test. Every command below was run on 12 November 2026 against [CyberGym](https://cybergym.hackergpt.app), a legal deliberately vulnerable practice target, against hackergpt.org, and against a throwaway site on `localhost`.

## What does an exposed .git folder or .env file give away?

An exposed `.git` folder can give away your source code and its whole history; an exposed `.env` file gives away the secrets your app runs with. They are different leaks with the same cause: a file that belongs to the project, not to the website, ended up where the web server serves it. MITRE catalogues the first as [CWE-527, exposure of a version-control repository](https://cwe.mitre.org/data/definitions/527.html), and the general case as [CWE-538](https://cwe.mitre.org/data/definitions/538.html). The [OWASP Web Security Testing Guide](https://wstg.owasp.org/latest/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management/04-Review_Old_Backup_and_Unreferenced_Files_for_Sensitive_Information/) lists `.git/` and `.env` among the paths a tester checks.

**The `.git` folder** is the repository itself. Its layout is [documented by Git](https://git-scm.com/docs/gitrepository-layout), and each file answers a question:

| Path | What it holds |
| --- | --- |
| `.git/HEAD` | the branch currently checked out |
| `.git/config` | the remote URL, sometimes with a token pasted into it |
| `.git/logs/HEAD` | every commit and checkout, with author names and emails |
| `.git/index` | the list of tracked files and their paths |
| `.git/objects/` | every version of every file, [compressed with zlib](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) |

Because each object is named by its hash and the hashes chain from `HEAD` to commits to trees to files, anyone who can read individual files under `.git/` can walk the chain and rebuild the source, with no directory listing needed. That includes every file that was ever committed and later deleted: a `.env` committed once in 2024 and removed the next day is still in the objects.

**The `.env` file** holds configuration the app reads at start-up. The [Twelve-Factor App](https://12factor.net/config) recommends keeping config in environment variables precisely so it is never checked in, and the [dotenv README](https://github.com/motdotla/dotenv) answers "Should I commit my `.env` file?" with "No." In practice it holds the database URL, payment and email API keys, and session signing secrets: the keys to everything the code alone would not open. The same secrets compiled into a front-end bundle are a related leak, covered in [hardcoded credentials in your JavaScript bundle](https://hackergpt.org/blog/hardcoded-credentials-javascript-bundle/).

## How do .git and .env files end up on a live site?

They arrive when the web root and the project folder are the same folder. The common ways, in our experience:

- **Deploying with `git clone` or `git pull` on the server**, then pointing the web server at the checkout. The `.git` folder comes along by definition.
- **Uploading the whole project** by FTP, `rsync` or a drag-and-drop host, instead of only the build output. The local `.env` goes up with it.
- **A container built with `COPY . .`** and no `.dockerignore`, served by a static file server inside the image.
- **A server that serves dot files.** Python's `python3 -m http.server` serves anything in the folder, dot files included, which is exactly how the `localhost` demo below leaks.

The last one has a subtle case worth knowing if you run Node. We served the same folder with `express.static()` and default options under two Express versions:

```text
Express 4.22.3 (serve-static 1.16.3)     Express 5.3.0 (serve-static 2.2.1)
/index.html    200                       /index.html    200
/.env          404                       /.env          404
/.git/HEAD     200                       /.git/HEAD     404
/.git/config   200                       /.git/config   404
```

Express 4 hid the `.env` file and served the repository. Its [serve-static 1.x documentation](https://github.com/expressjs/serve-static/blob/v1.16.3/README.md#dotfiles) explains why: the default "will not ignore the files within a directory that begins with a dot". In [serve-static 2.x](https://github.com/expressjs/serve-static/blob/v2.2.1/README.md#dotfiles), used by Express 5, the default is plain `'ignore'`. A quick check of `/.env` on an Express 4 app can therefore come back clean while `/.git/` is wide open, which is one reason to check both.

## How do I check if my site exposes .git or .env?

Request the files and test the body for content that only the real file has. Start with the trap, because it decides how you read every other result.

### Step 1: why a 200 does not mean exposed

CyberGym runs OWASP Juice Shop, an Angular single-page app. Here is what it answers for a `.git` file, a `.env` file and a path that cannot exist:

```bash
$ curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://cybergym.hackergpt.app/.git/HEAD
200 text/html; charset=UTF-8 75294
$ curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://cybergym.hackergpt.app/.env
200 text/html; charset=UTF-8 75294
$ curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://cybergym.hackergpt.app/no-such-page-at-all
200 text/html; charset=UTF-8 75294
```

Three different paths, the same `200`, the same type, the same 75,294 bytes. That is the app's `index.html`, returned for every unknown path so the browser-side router can decide what to show. A scanner that flags "`/.git/HEAD` returned 200" reports a finding that is not there. Asking for a path that cannot exist, as the third line does, gives you the baseline to compare against.

### Step 2: test the content

`.git/HEAD` starts with `ref: refs/` or a 40-character hash, `.git/config` starts with `[core]`, and a `.env` file is `KEY=value` lines. This script asks for each file once and tests the body for that signature:

```bash
#!/usr/bin/env bash
# check-exposed.sh - run it only against a site you own or may test.
# Usage: ./check-exposed.sh https://your-app.example
base="${1%/}"

check() {  # $1 = path, $2 = a pattern only the real file contains
  out=$(curl -s --max-time 10 -w '\n%{http_code}' "$base$1")
  code=${out##*$'\n'}
  body=${out%$'\n'*}
  if printf '%s\n' "$body" | grep -Eq "$2"; then
    echo "EXPOSED  $1  (HTTP $code, content matches)"
  else
    echo "ok       $1  (HTTP $code, not the real file)"
  fi
}

check /.git/HEAD       '^(ref: refs/|[0-9a-f]{40})'
check /.git/config     '^\[core\]'
check /.git/logs/HEAD  '^[0-9a-f]{40} [0-9a-f]{40} '
check /.env            '^[A-Za-z_][A-Za-z0-9_]*='
check /.env.production '^[A-Za-z_][A-Za-z0-9_]*='
```

Against CyberGym, every path answers `200` and none is the real file:

```text
$ ./check-exposed.sh https://cybergym.hackergpt.app
ok       /.git/HEAD  (HTTP 200, not the real file)
ok       /.git/config  (HTTP 200, not the real file)
ok       /.git/logs/HEAD  (HTTP 200, not the real file)
ok       /.env  (HTTP 200, not the real file)
ok       /.env.production  (HTTP 200, not the real file)
```

Against hackergpt.org, which is deployed by uploading only the `dist/` build output, every path is a plain `404`:

```text
$ ./check-exposed.sh https://hackergpt.org
ok       /.git/HEAD  (HTTP 404, not the real file)
ok       /.git/config  (HTTP 404, not the real file)
ok       /.git/logs/HEAD  (HTTP 404, not the real file)
ok       /.env  (HTTP 404, not the real file)
ok       /.env.production  (HTTP 404, not the real file)
```

Add any other names your stack uses, such as `.env.local` or `.env.staging`. Five paths are a starting point, not a complete list; the [backup files article](https://hackergpt.org/blog/backup-files-exposed-web-server/) covers the `.bak` and `.old` copies that leak the same way.

### Step 3: what a real exposure looks like

To see a positive, we made a two-file site on `localhost`: an `index.html` committed to a fresh git repository, plus a `.env` with placeholder values, served with `python3 -m http.server 8088`. The script finds it at once:

```text
$ ./check-exposed.sh http://localhost:8088
EXPOSED  /.git/HEAD  (HTTP 200, content matches)
EXPOSED  /.git/config  (HTTP 200, content matches)
EXPOSED  /.git/logs/HEAD  (HTTP 200, content matches)
EXPOSED  /.env  (HTTP 200, content matches)
ok       /.env.production  (HTTP 404, not the real file)
```

The files themselves say what a visitor learns. The config gives the author and the remote; the log gives every commit with a name and email:

```bash
$ curl -s http://localhost:8088/.git/HEAD
ref: refs/heads/main
$ curl -s http://localhost:8088/.git/config | tail -6
[user]
	email = demo@your-app.example
	name = Demo Dev
[remote "origin"]
	url = https://git.your-app.example/acme/shop.git
	fetch = +refs/heads/*:refs/remotes/origin/*
$ curl -s http://localhost:8088/.git/logs/HEAD
0000000000000000000000000000000000000000 3e5dca2115df4c9bd0ab637dd22299afab5c1104 Demo Dev <demo@your-app.example> 1794513662 -0500	commit (initial): Initial site
```

And one object fetched over HTTP and decompressed is a commit, pointing at the tree that lists the source files. Following those hashes is all it takes to rebuild the repository:

```bash
$ H=$(curl -s http://localhost:8088/.git/refs/heads/main)
$ curl -s "http://localhost:8088/.git/objects/${H:0:2}/${H:2}" \
    | python3 -c 'import sys,zlib; print(zlib.decompress(sys.stdin.buffer.read()).decode())'
commit 177 tree b949b593c91ab6aa19b236502136bcd5ebd19a71
author Demo Dev <demo@your-app.example> 1794513662 -0500
committer Demo Dev <demo@your-app.example> 1794513662 -0500

Initial site
```

The `.env` came back whole as well. We never print its values here, and neither should you in a ticket or chat: treat what you see as already leaked.

![Diagram: the same /.git/HEAD request gets a real file, an SPA fallback page, or a 404 from three servers](https://hackergpt.org/blog/exposed-git-folder-env-file/three-answers.webp) *The same request, three servers. Two answer 200, and only one of them is leaking anything.*

## What do I do if my .git folder or .env file is exposed?

Block the paths first, then rotate the secrets, then fix the deploy, in that order: blocking stops the next download, but anything already fetched stays fetched.

1. **Block access now.** Remove the folder and file from the web root, or add the server rule from the next section. Re-run the script until every line says `ok`.
2. **Rotate every secret.** Every value in the exposed `.env`, every token in `.git/config`, and every secret that was *ever* committed to the repository, since the objects carry the whole history. GitHub's guide on [removing sensitive data from a repository](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository) puts it in the same order: revoke or rotate the secret as the first step. Rewriting history does not un-leak a key someone already copied.
3. **Read your access logs** for requests to `/.git/` and `/.env`, and the logs of each rotated service for use you do not recognise, so you know what to investigate.
4. **Change how you deploy** so the next release cannot bring them back.

## How do I stop it happening again?

Deploy only the build output, and keep a server rule as a backstop. The first removes the files; the second covers the day someone deploys the old way.

**Deploy the artefact, not the checkout.** Build in CI or locally, then upload only the output folder (`dist/`, `build/`, `public/`). If the server must hold a checkout, point the web root at a subfolder of it that contains no `.git`. For containers, add `.git` and `.env*` to `.dockerignore`.

**Keep `.env` out of git.** Add `.env` and `.env.*` to `.gitignore`. In a Vite project, [the docs](https://vite.dev/guide/env-and-mode) add a second rule: any variable prefixed `VITE_` is compiled into the public bundle, so it must never hold a secret, wherever the file lives.

**Deny dot paths at the server.** In nginx, a [`location`](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) block with [`deny`](https://nginx.org/en/docs/http/ngx_http_access_module.html#deny) refuses them, keeping `.well-known` for certificates:

```nginx
location ~ /\.(?!well-known/) {
    deny all;
}
```

In Apache httpd 2.4, in the server or virtual-host config ([`DirectoryMatch`](https://httpd.apache.org/docs/2.4/mod/core.html#directorymatch) is not allowed in `.htaccess`; [`FilesMatch`](https://httpd.apache.org/docs/2.4/mod/core.html#filesmatch) is):

```apache
<DirectoryMatch "/\.git">
    Require all denied
</DirectoryMatch>
<FilesMatch "^\.env">
    Require all denied
</FilesMatch>
```

We served the `localhost` demo folder through macOS's bundled Apache with these two blocks: `/index.html` still returned `200`, while `/.env`, `/.git/HEAD` and `/.git/config` returned `403`.

In Express, upgrade to Express 5 or pass `express.static(dir, { dotfiles: 'deny' })` explicitly, and do not point it at the project root at all.

**Add the check to your pipeline.** A one-line step that fails the build when the output folder contains a `.git` or `.env` stops the next one from shipping:

```bash
test -z "$(find dist -name '.git' -o -name '.env*')" || { echo "secret files in dist/"; exit 1; }
```

Then run the curl script against production after each deploy. It is one check among the many in a [security misconfiguration](https://hackergpt.org/blog/security-misconfiguration-examples/) review: a clean result means these five paths are not served, not that nothing else is.

## Key takeaways

- Judge an exposure by the body, not the status code: `ref: refs/`, `[core]` or `KEY=value` lines mean the real file; a `200` with HTML on a single-page app does not.
- Check `.git/` and `.env` separately; Express 4's default static settings hide one and serve the other.
- If either was served, rotate every secret in the file and in the whole git history before anything else.
- Deploy only build output, and keep a dot-path deny rule on the server as a backstop.
- Put a `find` check in CI and a curl check after deploy, so the next release cannot reintroduce it.

## FAQ

### How do I know if my .git folder is publicly accessible?

Request `https://your-site/.git/HEAD` with curl and read what comes back. If the body is `ref: refs/heads/` followed by a branch name, or a 40-character hash, the folder is exposed. If you get your site's HTML page or a 404, it is not, at least for that path.

### Why does /.git/HEAD return 200 on my site when there is no .git folder?

Single-page apps and many hosting platforms return `index.html` for every unknown path, so the browser-side router can handle it. The status is `200`, but the body is your page, not the file. Compare it with a request for a path that cannot exist; if both return the same size, it is the fallback page.

### Is an exposed .git folder dangerous if the repository is already public on GitHub?

Less, but check what differs. The deployed copy can include local commits, branches or a remote URL with a token that were never pushed, and its config and logs show author emails. If the repository is private, an exposed `.git` folder can give away its full source and history.

### I deleted .env from my repository. Is it still exposed?

If it was ever committed, yes: it stays in the git history until the history is rewritten, and an exposed `.git` folder serves that history. Rotate every value it held; rewriting history afterwards keeps it from spreading further, but does not take back a copy someone already has.

### Does a .gitignore entry protect my .env file on the server?

No. `.gitignore` only stops git from tracking the file; it does nothing to a web server. If the file is in the web root and the server serves dot files, it is downloadable whether git tracks it or not.

## Sources

- [CWE-527: Exposure of Version-Control Repository to an Unauthorized Control Sphere](https://cwe.mitre.org/data/definitions/527.html), MITRE
- [CWE-538: Insertion of Sensitive Information into Externally-Accessible File or Directory](https://cwe.mitre.org/data/definitions/538.html), MITRE
- [WSTG-CONF-04: Review Old Backup and Unreferenced Files for Sensitive Information](https://wstg.owasp.org/latest/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management/04-Review_Old_Backup_and_Unreferenced_Files_for_Sensitive_Information/), OWASP Web Security Testing Guide
- [gitrepository-layout](https://git-scm.com/docs/gitrepository-layout), Git documentation
- [Git Internals: Git Objects](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects), Pro Git
- [III. Config](https://12factor.net/config), The Twelve-Factor App
- [dotenv README](https://github.com/motdotla/dotenv)
- [serve-static 1.16.3 README](https://github.com/expressjs/serve-static/blob/v1.16.3/README.md#dotfiles) and [serve-static 2.2.1 README](https://github.com/expressjs/serve-static/blob/v2.2.1/README.md#dotfiles), Express project
- [Removing sensitive data from a repository](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository), GitHub Docs
- [Env Variables and Modes](https://vite.dev/guide/env-and-mode), Vite
- [ngx\_http\_core\_module: location](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) and [ngx\_http\_access\_module: deny](https://nginx.org/en/docs/http/ngx_http_access_module.html#deny), nginx
- [core: DirectoryMatch and FilesMatch](https://httpd.apache.org/docs/2.4/mod/core.html#directorymatch), Apache HTTP Server 2.4

To see what else your own site shows the outside world, point [HackerGPT](https://hackergpt.app) at it, or practise on [CyberGym](https://cybergym.hackergpt.app) first.
