Credential storage security covers how and where database passwords are kept on the server: which files hold them, who can read those files, and whether anything is publishing them by accident. This page documents the 4 checks in the CtrlOps Credential Storage audit, in the order the audit runs them.
It is the gap between "the database has a strong password" and "that password is actually protected". Every threshold below is transcribed from the audit script itself, not from general advice. Where the script passes a value, this page says it passes; where the script warns rather than fails, this page says so and explains why the distinction matters.
Key takeaways
Four checks, and they run in sequence rather than independently: the first finds the files, the second judges them, and the fourth covers the per-user client files people forget exist.
- Three of the four are rated HIGH. Only the location check is MEDIUM, because a config file inside a web root is a risk that depends on server configuration, while a world-readable one is an exposure right now.
- Only one needs root. The first three read web roots that are world-traversable anyway. The client credential check needs root to see other accounts, and falls back to your own home directory without it.
- The whole audit takes about 10 seconds when CtrlOps runs it over your existing SSH connection, against roughly 5 to 10 minutes by hand.
- The default umask is the root cause. Most distributions create files at
644, so every fresh deployment reintroduces check 2 unless the deploy script sets permissions explicitly.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Config files in web root | MEDIUM | Credential files under a served directory | No |
| 2 | Config file permissions | HIGH | Those files readable by every local account | No |
| 3 | Git in web root | HIGH | Repository history under a served directory | No |
| 4 | Client credential files | HIGH | Per-user database client files readable by others | Yes |
/var/www, /srv/www and /usr/share/nginx/html, to a depth of four directories, for five filenames: .env, wp-config.php, config.php, settings.php and database.yml. That covers the overwhelming majority of Laravel, WordPress, Drupal, Rails and Node deployments. It does not cover an application served from /opt or a home directory, so a clean pass on an unusual layout means "nothing found in the usual places", not "nothing anywhere".Check 1: Are database config files inside a web root?
Web frameworks keep database credentials in a config file: .env for Laravel and Node, wp-config.php for WordPress, settings.php for Drupal, database.yml for Rails. When that file sits inside a directory the web server serves, one misconfiguration or one path traversal bug turns it into a download.
The audit searches the three common web roots for those five filenames and reports what it finds.
How to check manually
find /var/www /srv/www /usr/share/nginx/html -maxdepth 4 -type f \
\( -name '.env' -o -name 'wp-config.php' -o -name 'config.php' \
-o -name 'settings.php' -o -name 'database.yml' \) 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | No credential-bearing config file was found inside the searched web roots | Nothing to serve by accident in the usual locations. |
| WARN | One or more were found | The count and the first two paths are reported. One server misconfiguration serves them as plain text. Move the credentials outside the web root. |
There is no FAIL branch, and that is deliberate. A wp-config.php inside the WordPress root is where WordPress puts it, and PHP files are executed rather than served, so the file being there is not by itself an exposure. It becomes one the moment the server stops interpreting PHP, which happens during upgrades and misconfigurations more often than anyone expects. The finding is the location; check 2 is the one that judges the risk.
How to fix it
# Option 1: move it above the served root
sudo mv /var/www/html/.env /var/www/.env
# then point the application at the new path
# Option 2: block direct access. nginx:
location ~ /\.env { deny all; return 404; }
# Apache, in .htaccess:
# <Files ".env">
# Require all denied
# </Files>WordPress supports moving wp-config.php one directory above the web root with no configuration change at all: it looks there automatically.
Check 2: Are those config files world-readable?
Location decides whether the internet can reach a file. Permissions decide whether every account already on the server can read it. A config file at mode 644 is readable by every local user, every service account, and any application running under a different user that gets compromised.
The audit takes the file list from check 1 and reads the other digit of each mode: the last number. Anything that allows read there, meaning 4, 5, 6 or 7, is a finding.
How to check manually
find /var/www /srv/www /usr/share/nginx/html -maxdepth 4 -type f \
\( -name '.env' -o -name 'wp-config.php' -o -name 'config.php' \
-o -name 'settings.php' -o -name 'database.yml' \) 2>/dev/null \
| xargs -r stat -c '%a %U:%G %n'| Result | When | What it means |
|---|---|---|
| PASS | No credential file allows read for other | Only the owner and the group can read the database password. |
| FAIL | One or more allow read for other | The file paths are listed. Any local account can read the database password. Set mode 640 with the web server group. |
| SKIP | Check 1 found no config files to examine | There was nothing to judge. This is a skip, not a pass. |
Mode 640 passes. Only the last digit is tested, so 640, 600 and 400 all pass, and 644, 664 and 666 all fail. That is the right line to draw: the web server needs to read the file, so demanding 600 would break the application, while 640 with the web server as the group gives the application access and nobody else.
How to fix it
# Application config: readable by the web server group, nobody else
sudo chmod 640 /var/www/html/.env
sudo chown www-data:www-data /var/www/html/.env
# WordPress, where root owns the file and the web server only reads it
sudo chmod 640 /var/www/html/wp-config.php
sudo chown root:www-data /var/www/html/wp-config.php644, and that applies to files written by a git pull, a CI deploy, an scp, or an editor that rewrites rather than edits in place. Fixing the permission by hand fixes today. Adding a chmod 640 step to the deployment script is what fixes it permanently, and it is the single most common reason this check passes once and fails again a week later.Check 3: Is there a git directory in a web root?
Deploying with git pull inside the web root leaves a .git directory containing the entire repository history: every commit, every deleted file, every credential that was ever committed and later removed. If the server serves it, the whole repository can be reconstructed from the outside with off-the-shelf tooling.
The audit looks for .git directories at the top of each web root and one or two levels down.
How to check manually
# What the audit looks at
ls -d /var/www/.git /var/www/*/.git /var/www/*/*/.git \
/srv/www/*/.git /usr/share/nginx/html/.git 2>/dev/null
# Whether it is actually reachable, which the audit does NOT test
curl -s -o /dev/null -w '%{http_code}\n' https://your-domain.com/.git/HEAD| Result | When | What it means |
|---|---|---|
| PASS | No .git directory was found in the searched web roots | Repository history is not sitting under a served path. |
| WARN | One or more were found | The first three paths are listed. Block them at the web server or deploy so they are not there at all. |
The audit checks presence, not reachability. It never makes an HTTP request, because every check in this catalog is a read-only local command. So a .git your web server already blocks still warns. That is the intended behaviour rather than a false positive: the deny rule is one config change away from being gone, and the warning is telling you the exposure is one mistake deep rather than impossible. Run the curl above yourself to find out which of the two situations you are in.
How to fix it
# Block it at the web server. nginx:
location ~ /\.git { deny all; return 404; }
# Apache:
# RedirectMatch 404 /\.git
# Better: deploy so it is never under the served root
git clone repo /var/www/app # web root becomes /var/www/app/public
rsync -a --exclude='.git' src/ /var/www/html/.git directory recover deleted files and old commits, so a password that was committed once and removed in the next commit is still in there. If this check has ever found a reachable .git on a production host, rotate every credential that repository has ever contained rather than assuming the current state of the working tree is what leaked.Check 4: Are database client credential files locked down?
Database clients let you store credentials for convenience: ~/.my.cnf for MySQL, ~/.pgpass for PostgreSQL, ~/.mongoshrc.js for the MongoDB shell, plus ~/.dbshell and ~/.rediscli_auth history and auth files. They hold plaintext passwords. If another account can read them, that account has your database.
The audit globs those five filenames under /root and every directory in /home, then reads both the group and other digits. It flags anything where either is non-zero.
How to check manually
sudo stat -c '%a %U %n' \
/root/.my.cnf /root/.pgpass /root/.mongoshrc.js /root/.dbshell /root/.rediscli_auth \
/home/*/.my.cnf /home/*/.pgpass /home/*/.mongoshrc.js /home/*/.dbshell /home/*/.rediscli_auth \
2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | Every client credential file found is owner-only | Group and other have no access. Modes 600 and 400 both qualify. |
| FAIL | One or more grant any access to group or other | Each path is listed with its actual mode. These hold plaintext database passwords. Set them to 600. |
| SKIP | No client credential files were found at all | Nothing to judge. Common on servers where nobody uses an interactive database client. |
This check is stricter than check 2, and the difference is who needs to read the file. An application config has to be readable by the web server, so 640 is correct. A personal client credential file has exactly one legitimate reader, so 640 fails here while it passes there.
Root is the fallback rather than a hard requirement: without it the audit checks only your own home directory and reports on that, which finds your files but not the ones belonging to the deploy user.
How to fix it
# Your own
chmod 600 ~/.my.cnf ~/.pgpass ~/.mongoshrc.js ~/.dbshell ~/.rediscli_auth 2>/dev/null
# Every account that has them
sudo chmod 600 /root/.my.cnf /root/.pgpass 2>/dev/null
sudo chmod 600 /home/*/.my.cnf /home/*/.pgpass 2>/dev/nullpsql refuses to use a .pgpass file with permissions above 600 and tells you so, which means the PostgreSQL half of this check is usually already clean. MySQL reads ~/.my.cnf at whatever mode it finds, including 644, without a word. That asymmetry is why this check exists: the file that silently works is the one nobody thinks to look at.What this audit does not cover
Being explicit about scope is part of the point of publishing thresholds. This audit reads where credentials sit on disk and who can reach them.
- Whether the database requires a credential at all. That is the authentication audit. Protecting a password that the database does not ask for achieves nothing.
- Whether the credential travels encrypted. That is the transport encryption audit.
- HTTP reachability. No check here makes a network request. Checks 1 and 3 report that a file or directory exists under a web root, not that a browser can fetch it. The
curlcommands on this page are for you to run, not something the audit does. - Non-standard application paths. Only
/var/www,/srv/wwwand/usr/share/nginx/htmlare searched, to a depth of four. An application in/optor a home directory is invisible to checks 1 and 2. - Environment variables. Credentials injected as environment variables rather than written to a file leave nothing on disk for a file-based check to find. That is a better pattern, and it is also why a clean pass here is not the whole answer.
- Secrets managers. Vault, AWS Secrets Manager and similar are out of scope for a host-level read-only check.
The manual audit problem at scale
Running these 4 checks by hand takes 5 to 10 minutes per server. It is the shortest database audit in the set, and the one where the finding is most likely to come back on its own.
That is the part worth planning around. The other database audits catch configuration that stays fixed once you fix it: a trust line stays gone, an anonymous account stays dropped. This one catches the output of a process. Every deployment writes files, the default umask makes them 644, and if the deploy script does not set permissions then the next release reintroduces exactly the finding you closed last month.
Which means the useful question is not "is this server clean today" but "did it go back to being wrong since the last release". That takes a stored previous result to answer, and it is the same server management drift problem that compounds across every audit category.
How CtrlOps runs all 4 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Credential Storage audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.
Watch it run
The recording below walks through the SSH & Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal. The written steps that follow cover the same ground if you would rather read than watch.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Credential Storage
Choose the Credential Storage audit from the Database category. All 4 checks are listed with their descriptions, severity levels and the estimated run time (about 10 seconds in total). Toggle off any check you want to skip.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.
Step 4: Read the report
You get a summary containing:
- A hardening score out of 100
- A severity breakdown: how many HIGH, MEDIUM and LOW findings
- Pass, warning, failed and skipped counts
- A findings table you can sort and filter
Step 5: Fix what failed
Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the hardening score move. This is the audit to re-run after every deployment rather than on a schedule, because deployments are what reintroduce the findings.
| Task | By hand, 8 servers | CtrlOps, 8 servers |
|---|---|---|
| Run all 4 checks | About 1 hour | About 80 seconds |
| Re-check after a deploy | Manual, so usually skipped | Automatic |
| Get a scored report | Not available | Automatic |
| Export a PDF for a client | Not available | One click |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
Conclusion
A database password is only as protected as the file it sits in. A config file under a served directory, a 644 mode from the default umask, a .git left behind by a deploy, a ~/.my.cnf nobody remembered writing: each of those hands over the credential without touching the database at all.
Three of these four checks are HIGH severity because each is an exposure that already exists rather than a risk that might materialise. The CtrlOps Security Audit runs the set in about 10 seconds per server, agentlessly, which makes it cheap enough to run after every release instead of once a quarter.
The neighbouring checklists cover the other database layers: authentication, configuration and hardening, least-privilege permissions, network isolation and transport encryption.
Frequently asked questions
Outside the served web root, in a file owned by the application user with mode 640 and the web server as the group, or better still in environment variables injected by the service manager so nothing sits on disk at all. What matters most is that the file is not under a directory the web server can serve and that its last permission digit is zero.
For an application config, yes, and this audit passes it. The web server has to read the file, so 600 would break the application; 640 with the web server as the group gives it access and denies everyone else. For a personal database client file such as ~/.my.cnf the answer is no: only one account should ever read it, so check 4 fails anything above 600.
Because it checks whether the directory exists, not whether it is currently reachable. Every check in this catalog is a read-only local command and none of them make HTTP requests. A blocked .git is one config change away from an exposed one, so the warning reflects that the exposure is one mistake deep rather than impossible. Run curl https://your-domain/.git/HEAD yourself to see which situation you are in.
Nothing, until something changes. Then a server misconfiguration, a path traversal bug, or a virtual host that lost its deny rule serves the file as plain text, and the database password, API keys and application secrets go with it. That conditional risk is why this check warns rather than failing, and why moving the file out is better than adding a deny rule.
No. Git history is permanent in practice: a credential committed once and removed in the next commit is still recoverable from the repository, including from a reconstructed .git directory. Add .env, wp-config.php, database.yml, *.pem and *.key to .gitignore before the first commit, and if something has already been committed, rotate it rather than only deleting it.
Because the default umask on most distributions creates files at 644, and that applies to anything written by a git pull, a CI deploy, an scp, or an editor that rewrites the file instead of editing in place. Fixing the mode by hand fixes today. Adding an explicit chmod to the deployment script is what stops it recurring.
Only for the fourth check, which globs /root and every directory under /home for database client credential files. Without root it falls back to checking your own home directory, which finds your files but not the ones belonging to the deploy user or another admin. The first three checks read web roots that are traversable without privileges.
It is a deliberate design difference. psql validates the permissions on .pgpass before using it and refuses anything above 600 with an explicit error. MySQL reads ~/.my.cnf at whatever mode it finds and says nothing. That is exactly why this check exists: the file that keeps working while being world-readable is the one nobody notices.
Checks 1 and 2 search only /var/www, /srv/www and /usr/share/nginx/html, to a depth of four directories. An application served from /opt, /home or a custom path is invisible to them, so a pass means "nothing found in the usual places" rather than "nothing anywhere". Run the find command on this page against your actual document root to cover the gap.
It searches the three common web roots for five credential-bearing filenames, reads the other permission bit on everything it finds, globs the web roots for .git directories, and stats the per-user database client files under /root and /home. All four checks are read-only, nothing is installed, and the whole audit takes about 10 seconds over your existing SSH connection.
Once you have more than a couple of servers, or credentials that need rotating on a schedule, or a compliance requirement for an access trail. At that point a secrets manager such as HashiCorp Vault, AWS Secrets Manager or Doppler gives you rotation, audit logs and centralised revocation that file permissions cannot. This audit still applies to whatever files remain on the host.