# Rotating the database password without downtime: the thing you've been putting off

Everyone agrees the database password should rotate. Almost nobody's does, and the reason isn't laziness. It's that the first time you try, something breaks: a connection pool holding the old password, a Lambda that cached it at cold start, a background job that read it from an environment variable set at deploy time. So the rotation gets rolled back, a ticket gets opened, and the password stays the same for two years.

We rotate ours every thirty days, automatically, and the application doesn't notice. This is the mechanism, which is mostly AWS's, and the three changes to the application that made it safe, which are ours.

## Why the naive rotation breaks things

Rotation with a single database user goes: generate a new password, `ALTER USER app PASSWORD 'new'`, update the secret. Between the `ALTER` and the moment every consumer has re-read the secret, any *new* connection with the old password is refused. Existing connections survive, because Postgres authenticates at connect time, but a pool that opens a new connection during that window fails, and a service that reads the secret only at start-up fails on every reconnect until it's restarted.

The window can be seconds if everything re-reads promptly. It can be hours if something caches. In practice it's "until the next deploy", because that's when environment variables get refreshed, and that's the outage.

## Alternating users: the rotation that never invalidates a password in use

Secrets Manager's multi-user rotation strategy uses two database users, `app` and `app_clone`, with identical grants. At any moment one of them is the *current* user in the secret and the other is idle. Rotation changes the password of the *idle* one, tests it, and then flips the secret to point at it. The user that was current, whose password every consumer might be holding, is untouched until the next rotation thirty days later, by which time every consumer has re-read the secret many times over.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Timeline of alternating-user rotation. Day 0: secret points at user app with password P1; app_clone has password P0, idle. Day 30 rotation: the rotation Lambda sets app_clone's password to P2, tests a connection, then flips the secret to app_clone. Consumers holding app P1 keep working; new connections use app_clone P2. Day 60: rotation sets app's password to P3 and flips back. At no point is a password that a consumer might hold invalidated.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Alternating users · the password in use is never the one being changed</text>
<line x1="60" y1="120" x2="860" y2="120" stroke="#2a3150" stroke-width="2"/>
<text x="60" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">day 0</text><text x="460" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">day 30 · rotation</text><text x="860" y="150" text-anchor="middle" fill="#9aa3c7" font-size="11">day 60 · rotation</text>
<rect x="60" y="60" width="400" height="22" rx="4" fill="#4fffb0" opacity="0.7"/><text x="260" y="75" text-anchor="middle" fill="#0d1120" font-size="11" font-weight="700">secret → app · P1 · every consumer uses this</text>
<rect x="60" y="88" width="400" height="22" rx="4" fill="#2a3150"/><text x="260" y="103" text-anchor="middle" fill="#9aa3c7" font-size="11">app_clone · P0 · idle</text>
<rect x="460" y="60" width="400" height="22" rx="4" fill="#2a3150"/><text x="660" y="75" text-anchor="middle" fill="#9aa3c7" font-size="11">app · P1 · still valid, idle, nobody's connection breaks</text>
<rect x="460" y="88" width="400" height="22" rx="4" fill="#4fffb0" opacity="0.7"/><text x="660" y="103" text-anchor="middle" fill="#0d1120" font-size="11" font-weight="700">secret → app_clone · P2 · new connections use this</text>
<line x1="460" y1="50" x2="460" y2="125" stroke="#ffd166" stroke-width="2" stroke-dasharray="5,3"/>
<text x="460" y="176" text-anchor="middle" fill="#ffd166" font-size="11">1 · set app_clone password to P2   2 · test connect   3 · flip secret</text>
<text x="450" y="206" text-anchor="middle" fill="#9aa3c7">A consumer holding P1 keeps working for 30 more days. By then it has re-read the secret many times.</text>
<text x="450" y="228" text-anchor="middle" fill="#9aa3c7">Day 60 does the same in reverse: app gets P3, the secret flips back.</text>
</g>
</svg>
</div>

In CDK, on a cluster whose credentials came from a generated secret, it's a few lines:

```ts
cluster.addRotationMultiUser('Rotation', {
  secret: appUserSecret,                 // the secret for the 'app' user, with masterarn set
  automaticallyAfter: Duration.days(30),
  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
});
```

CDK deploys AWS's rotation Lambda from the Serverless Application Repository into the VPC, wires it to the secret, and schedules it. The Lambda needs to reach both the database (it's in the VPC) and Secrets Manager (an [interface endpoint or a NAT](/en/blog/nat-gateway-the-most-expensive-line-you-do-not-see); we use the endpoint). The `app_clone` user is created by the Lambda on the first rotation if it doesn't exist, with the same grants as `app`, which is the one part worth checking by hand: if `app` has grants added later by a migration, `app_clone` needs them too. We handle that by granting to a *role* that both users are members of, so grants are made once.

## The three application changes

The rotation mechanism is safe by construction. The application still has to read the *new* secret at some point, and how it does that decides whether rotation is invisible or a slow-motion outage.

**1. Read the secret at connect time, not at start time.** The pool's connection factory calls Secrets Manager (through a cache with a five-minute TTL) each time it opens a connection. A long-lived service picks up a new secret within five minutes of a flip, without a restart. App Runner's `runtimeEnvironmentSecrets` injects the value at instance start, which is fine for a service that redeploys weekly and wrong for one that runs for a month, so for the database credentials specifically we read from Secrets Manager in code rather than from the environment.

**2. On an authentication failure, refetch once and retry.** Belt and braces: if a connection attempt fails with `28P01` (invalid password), invalidate the cache, fetch the secret again, and retry once. This covers the case where the cache TTL hasn't expired at the exact moment of the flip. It's ten lines and it has fired in production exactly as many times as we've rotated: about once a month, silently, in the logs.

```ts
async function connect(): Promise<Client> {
  try {
    return await open(await creds.get());
  } catch (e) {
    if (isAuthError(e)) { creds.invalidate(); return open(await creds.get()); }
    throw e;
  }
}
```

**3. Let RDS Proxy do it for the Lambdas.** The functions in the VPC connect through RDS Proxy, and the proxy authenticates to the database with the secret itself: it watches Secrets Manager and picks up rotations on its own. The functions authenticate to the *proxy* with IAM, so they never hold a database password at all. Rotation, for them, is a non-event by design. It also fixes the connection-storm problem that Lambdas have with Postgres, which is [the reason we'd recommend the proxy anyway](/en/blog/aurora-serverless-v2-review).

<div class="article-figure">
<svg viewBox="0 0 900 220" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two consumer paths. App Runner service: the pool reads the secret from Secrets Manager through a 5-minute cache at every new connection, and on an auth error invalidates the cache and refetches once. Lambda functions: they authenticate to RDS Proxy with IAM and hold no password; the proxy reads the secret itself and follows rotations. Both paths reach Aurora. The rotation Lambda changes the idle user's password every 30 days.">
<defs><marker id="arrR" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="30" width="200" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="54" text-anchor="middle" fill="#f1f3ff" font-weight="700">App Runner service</text><text x="115" y="72" text-anchor="middle" fill="#9aa3c7" font-size="11">secret at connect time · 5-min cache</text><text x="115" y="90" text-anchor="middle" fill="#9aa3c7" font-size="11">auth error → refetch once</text>
<rect x="15" y="130" width="200" height="70" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="154" text-anchor="middle" fill="#f1f3ff" font-weight="700">Lambda functions</text><text x="115" y="172" text-anchor="middle" fill="#9aa3c7" font-size="11">IAM auth to the proxy</text><text x="115" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">never hold a password</text>
<rect x="330" y="30" width="200" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="430" y="54" text-anchor="middle" fill="#4fffb0" font-weight="700">Secrets Manager</text><text x="430" y="72" text-anchor="middle" fill="#9aa3c7" font-size="11">app / app_clone · AWSCURRENT</text><text x="430" y="90" text-anchor="middle" fill="#9aa3c7" font-size="11">rotation Lambda every 30 days</text>
<rect x="330" y="130" width="200" height="70" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="430" y="154" text-anchor="middle" fill="#f1f3ff" font-weight="700">RDS Proxy</text><text x="430" y="172" text-anchor="middle" fill="#9aa3c7" font-size="11">reads the secret itself</text><text x="430" y="190" text-anchor="middle" fill="#9aa3c7" font-size="11">follows rotations · pools connections</text>
<rect x="680" y="80" width="200" height="70" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="780" y="104" text-anchor="middle" fill="#f1f3ff" font-weight="700">Aurora</text><text x="780" y="122" text-anchor="middle" fill="#9aa3c7" font-size="11">users app and app_clone</text><text x="780" y="140" text-anchor="middle" fill="#9aa3c7" font-size="11">same grants via one role</text>
<line x1="217" y1="65" x2="328" y2="65" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<line x1="217" y1="165" x2="328" y2="165" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<line x1="430" y1="102" x2="430" y2="128" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<path d="M217,80 C300,115 560,115 678,110" fill="none" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
<line x1="532" y1="165" x2="678" y2="125" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrR)"/>
</g>
</svg>
</div>

## The first rotation, in staging, with a stopwatch

We didn't turn it on in production first. In staging, we triggered a rotation manually (`aws secretsmanager rotate-secret`), watched the Lambda's log, and ran a load generator against the API throughout. Results from the first attempt:

| Moment | What happened |
|---|---|
| T+0 s | Rotation starts; Lambda sets `app_clone` password, tests it, flips the secret |
| T+4 s | Flip complete. Every existing connection still fine (they hold `app`) |
| T+0 to T+300 s | New connections from the App Runner pool still use the cached `app` credentials. Still fine, because `app` is still valid |
| T+300 s | Cache expires; next new connection fetches `app_clone`. Works |
| T+5 min to T+30 days | Both users valid. No consumer can be caught out |

Zero errors in the load generator. The one thing that failed was a cron job in a separate account that had the password in an environment variable: it kept working for thirty days (its user was still valid) and then broke on the *second* rotation, which is exactly the delayed failure the alternating strategy produces for consumers that don't re-read. That's the point of doing it in staging first. The cron job now reads the secret.

## The checklist

- Multi-user rotation, never single-user. The extra database user is free and it's the whole difference.
- Both users get grants through a shared role, so a migration that grants to the role covers both.
- The rotation Lambda lives in the VPC and needs a path to Secrets Manager: interface endpoint or NAT.
- Every consumer reads the secret at connect time through a short-TTL cache, or goes through RDS Proxy with IAM.
- Refetch once on `28P01`.
- First rotation in staging, manually triggered, under load, with the log open.
- Then grep every environment variable, `.env`, cron and script for the old password. Whatever still has it will break in thirty to sixty days. Better to find it now.

Thirty-day rotation, six months, six rotations, zero incidents. The password nobody has read has changed six times and nobody noticed, which is what a secret is supposed to feel like.

If your database password has a birthday, [we can get it rotating in a day](/contact), staging first.
