# Managing Database Passwords and Secrets: Best Practices

> How to store, rotate, and scope database credentials: secrets managers, dynamic credentials, the application path vs the human path, and the mistakes that leak passwords.

Tianzhou | 2026-06-04 | Source: https://www.bytebase.com/blog/database-secrets-management-best-practices/

---

Of all the secrets in a stack, the database password is almost always the least governed. We have worked with teams running databases of every size, and the pattern repeats: the password gets pasted into an env file, committed once, and then forgotten until a breach or an audit forces the question. This post shares what we have learned about managing database passwords and secrets properly. Where they should live, how they rotate, and why the application path and the human path need different treatment.

## Store the secret in one place

A credential should exist in exactly one system of record (a secrets manager like Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or Infisical), and everything else reads it from there at runtime. The password lives there, applications fetch it at startup, and no human ever copies it somewhere else.

Why does this matter? Because the moment a password lives in three env files, two CI variables, and someone's `.pgpass`, you can no longer rotate it. You cannot even answer a basic question: who actually holds this credential today?

```mermaid
flowchart LR
    BAD["<b>Scattered</b><br/>env file · CI variables · .pgpass<br/>source code · wiki / chat"]
    DB[("Database")]
    GOOD["<b>One source of record</b><br/>apps fetch one credential<br/>from a secrets manager"]

    BAD ==>|"a copy everywhere<br/>can't rotate"| DB
    DB --- GOOD

    style BAD fill:#fee2e2,stroke:#b91c1c
    style GOOD fill:#dcfce7,stroke:#15803d
```

Even with a secrets manager, credentials still leak, and rarely in sophisticated ways. A password simply ends up somewhere it can be read. Three places to watch:

- **Code.** A credential in source is in every clone, every fork, and the git history forever. Deleting the line later does not remove it. Run a secret scanner (`gitleaks`, `trufflehog`, GitHub secret scanning) in CI, and if one slips through, rotate it.
- **CI/CD.** Use the platform's secret store, never plaintext in the workflow file. A proper store is write-only: GitHub Actions secrets cannot be read back even by an admin. The residual risk is exfiltration. Anyone who can edit a workflow can add a step that leaks the secret, so gate who changes pipelines. Better still, skip the stored secret with OIDC and mint a short-lived credential at run time.
- **Logs.** The quiet one. A connection string with an inline password gets logged by an ORM in debug mode, or by an exception handler dumping the config. Never put a password on a command line, and never log a full connection string.

A secrets manager answers *where the password is stored*. It does not answer *who may use it*, or *what they do once they connect*. That second question is the harder one, and it is the one most teams never get to.

## Rotate on a schedule, not on an incident

A credential that never changes has, in effect, already leaked. You just don't know when. Rotation bounds the blast radius: if a password is only valid for 30 days, then a leak on day one is dead by day thirty. There are two common models.

**Scheduled rotation.** The manager generates a new password, updates the database, and updates the stored secret; applications pick up the new value on their next fetch. The one thing to watch is the seam. For a short grace window, the database should accept both the old and the new password, so in-flight connections don't fail.

```mermaid
flowchart LR
    A["Generate<br/>new password"] --> B["Update<br/>database"]
    B --> C["Grace window<br/><b>old + new valid</b>"]
    C --> D["Apps fetch<br/>new password"]
    D --> E["Expire<br/>old password"]
    E -.->|next cycle| A

    style C fill:#fef3c7,stroke:#a16207
```


**Dynamic credentials.** Instead of rotating one shared password, the manager mints a fresh credential per consumer on demand and revokes it when the TTL expires. There is no standing secret left to steal. This fits short-lived work especially well: a CI migration, a batch ETL run, a serverless invocation. The credential lives only as long as the job, and then it is gone.

```mermaid
flowchart LR
    A["Job starts"] --> B["Request<br/>credential"]
    B --> C["Mint scoped<br/>credential<br/><b>short TTL</b>"]
    C --> D["Job connects<br/>and runs"]
    D --> E["TTL expires,<br/>credential revoked"]

    style C fill:#fef3c7,stroke:#a16207
```

Either way, the mindset is the same. **Rotation is a calendar event, not a breach response.** If the only time you ever rotate is *after* an incident, then the credential was already compromised for its entire life.

## Separate the application path from the human path

If there is one thing to take away from this post, this is it.

An application connects to the database the same way every time: the same credential, the same handful of statements, millions of times a day. A human connects in a completely different manner. An ad-hoc query here, a schema change there, a 2 a.m. investigation when something is on fire. Two different workloads, two different risk profiles, and yet most teams hand them the same credential.

**The application path** is a machine-to-machine problem. The service account is fetched from the manager, known to no human, and scoped to exactly what it needs, and nothing more:

- `SELECT`, `INSERT`, `UPDATE`, `DELETE` on the tables it touches. No `DROP`. No `ALTER`.
- Migration accounts kept separate from runtime accounts.
- Read-only jobs on credentials that physically cannot write.
- One account per service, so a single compromise can't span two.

Least privilege won't stop a leak, but it changes the consequence. It turns a leaked credential from a catastrophe into a contained incident. Better yet, remove the password altogether. IAM-based authentication (RDS IAM, Cloud SQL IAM, Azure AD) lets a service authenticate with a short-lived token tied to its cloud identity, and impersonation on GCP or `AssumeRole` on AWS removes the static key on disk too. At that point the credential problem disappears into the identity layer.

**The human path** is an identity and authorization problem. A human should hold no standing password at all. They authenticate as themselves, get access scoped to the task, and have every action logged against their own name rather than a shared `analyst` login. How that works is the next section.

Conflate the two and you get the worst credential in any database: the shared admin password, known by the whole team, never rotated precisely because rotating it would page everyone at once. Separate the two paths, and that credential no longer has any reason to exist.

## Governing the human path

You can store a human's password in a vault, but the controls stop the moment they check it out. The vault knows the credential was retrieved; it has no idea which queries were run afterward, whether a masked column was read, or whether that production change was reviewed first. A secrets manager governs *possession* of the credential. It does not govern *use*.

This is the gap we built [Bytebase](/) to fill, deliberately the half that a secrets manager does not cover. On the human path, people hold no database password at all. They authenticate as themselves and reach the database through a governed path, where the controls live at the statement level rather than the connection:

- **Query-level authorization.** Access is evaluated per statement, against the specific principal, table, and action, not granted once at connect time.
- **Dynamic masking.** Sensitive columns return masked values on the human path, even when the underlying role has full `SELECT`.
- **Just-in-time access.** Access is requested, scoped to a database and a time window, and then expires on its own. No standing credential, nothing to rotate.
- **Audit by identity.** Every statement is logged against the person who ran it, so "who ran this query on March 3rd" has a name for an answer rather than a shared login.

```mermaid
flowchart LR
    H["Human"] --> BB
    AG["AI agent"] --> BB

    subgraph BB["Bytebase, governed path"]
        direction TB
        C1["Query-level authorization"] ~~~ C2["Dynamic data masking"] ~~~ C3["Just-in-time access"] ~~~ C4["Audit by identity"]
    end

    BB --> DB

    DB["<b>Databases</b><br/>PostgreSQL · MySQL · Oracle · SQL Server<br/>Snowflake · MongoDB · BigQuery · Redshift<br/>+ more"]

    BB -.->|"fetch DB credential"| SM["<b>Secrets manager</b><br/>Vault · AWS · GCP · Azure"]

    style SM fill:#fef3c7,stroke:#a16207

    style BB fill:#eff6ff,stroke:#1d4ed8
```

The division of labor is clean. The secrets manager owns the application path: store the password, scope the account, rotate or mint it short-lived. The human path runs on identity and per-statement control, so there is no human-held password to manage at all.

## Where to start

This is the order we suggest:

1. **Inventory the standing credentials.** Env files, CI variables, `.pgpass`, internal wikis. The list almost always runs longer than people expect.
2. **Move them into one secrets manager.** Then delete every copy that lives outside it.
3. **Split application from human.** Scope the service accounts, and take humans off standing passwords and onto an audited, identity-based path.
4. **Turn on rotation.** Scheduled at a minimum, dynamic wherever the workload allows.

None of this is exotic. Most teams already run a secrets manager and already have an identity provider sitting right there. The real work is drawing a clear line between the two paths, application credentials on one side, human access on the other, and then holding that line. Do that, and the shared admin password that everyone knows and nobody wants to rotate never gets created in the first place.
</content>
</invoke>