A credential expires only when the system deciding access checks an applicable deadline. Writing an expiry date in an inventory does not change what a database or API accepts. A short lifetime can reduce the period during which a stolen credential starts new access, but its effect depends on the authentication mechanism and on what happens to sessions or other credentials already created with it.

When it breaks

Expiry does not undo earlier actions. Data already copied remains copied, and a session established before the deadline may continue working. An attacker may also have created another access path while authorized. Treat lifetime as one limit on future use, alongside scope, revocation, session management, and investigation of actual effects. It does not put a universal ceiling on the damage from exposure.

PostgreSQL’s VALID UNTIL applies to a role’s password, not to the role’s existence or all authentication methods. A connection using a non-password method does not gain that deadline check. Testing expiry through a local trust rule can therefore misleadingly suggest that the deadline is broken. Verify the authentication path actually used by the client.

Rotation and renewal are separate changes. Replacing a password does not necessarily move its expiry date, and extending a deadline does not replace the value. A refresh design that updates only one can leave applications unable to reconnect or leave the old value usable. Successful access through an existing pool connection does not prove that replacement credentials can establish fresh connections.

Check it quickly

Use an isolated Linux environment with PostgreSQL 18 programs on PATH, running as a non-root user, and an unused loopback port 55477. Save this as a shell script and run it with sh. It initializes a disposable cluster, uses explicitly invented passwords, binds TCP only to loopback, and removes its temporary directory on exit. Local trust is included solely to demonstrate a different authentication path:

#!/bin/sh
set -eu
lab_root=$(mktemp -d /tmp/credential-expiry.XXXXXX)
cleanup() {
  if test -f "$lab_root/data/postmaster.pid"; then
    pg_ctl -D "$lab_root/data" -m immediate -w stop >/dev/null
  fi
  rm -rf "$lab_root"
}
trap cleanup EXIT
initdb -D "$lab_root/data" --locale=C --auth-local=trust --auth-host=scram-sha-256 >/dev/null
pg_ctl -D "$lab_root/data" -l "$lab_root/server.log" \
  -o "-k $lab_root -p 55477 -c listen_addresses=127.0.0.1" -w start >/dev/null
admin() {
  psql -X -qAt -v ON_ERROR_STOP=1 -h "$lab_root" -p 55477 -d postgres "$@"
}
# Both passwords below are invented, disposable lab fixtures.
admin <<'SQL'
SET password_encryption='scram-sha-256';
CREATE ROLE article_expiry LOGIN PASSWORD 'LAB-ONLY-OLD-PASSWORD';
SQL
mkfifo "$lab_root/input"
PGPASSWORD=LAB-ONLY-OLD-PASSWORD psql -X -qAt -v ON_ERROR_STOP=1 \
  -h 127.0.0.1 -p 55477 -U article_expiry -d postgres \
  <"$lab_root/input" >"$lab_root/held.out" 2>"$lab_root/held.err" &
held_pid=$!
exec 3>"$lab_root/input"
printf "SELECT 'ready';\n" >&3
attempt=0
until grep -qx ready "$lab_root/held.out"; do
  attempt=$((attempt + 1))
  test "$attempt" -lt 100
  sleep 0.05
done
admin <<'SQL'
SELECT format('ALTER ROLE article_expiry VALID UNTIL %L',
              clock_timestamp()+interval '1 second') \gexec
SELECT pg_sleep(1.2);
SELECT 'deadline passed='||(rolvaliduntil<clock_timestamp())
FROM pg_authid WHERE rolname='article_expiry';
SQL
if PGPASSWORD=LAB-ONLY-OLD-PASSWORD psql -X -qAt -h 127.0.0.1 -p 55477 \
  -U article_expiry -d postgres -c 'SELECT 1' >"$lab_root/new.out" 2>"$lab_root/new.err"; then
  exit 1
fi
grep -q 'password authentication failed' "$lab_root/new.err"
printf '%s\n' 'fresh SCRAM login rejected after expiry'
printf "SELECT 'held after expiry='||current_user;\n" >&3
psql -X -qAt -v ON_ERROR_STOP=1 -h "$lab_root" -p 55477 \
  -U article_expiry -d postgres -c "SELECT 'local trust login='||current_user"
admin <<'SQL'
SET password_encryption='scram-sha-256';
ALTER ROLE article_expiry PASSWORD 'LAB-ONLY-NEW-PASSWORD';
SQL
if PGPASSWORD=LAB-ONLY-NEW-PASSWORD psql -X -qAt -h 127.0.0.1 -p 55477 \
  -U article_expiry -d postgres -c 'SELECT 1' >"$lab_root/rotated.out" 2>"$lab_root/rotated.err"; then
  exit 1
fi
grep -q 'password authentication failed' "$lab_root/rotated.err"
printf '%s\n' 'rotating password alone preserves expired deadline'
admin <<'SQL'
SELECT format('ALTER ROLE article_expiry VALID UNTIL %L',
              clock_timestamp()+interval '1 hour') \gexec
SQL
if PGPASSWORD=LAB-ONLY-OLD-PASSWORD psql -X -qAt -h 127.0.0.1 -p 55477 \
  -U article_expiry -d postgres -c 'SELECT 1' >"$lab_root/old.out" 2>"$lab_root/old.err"; then
  exit 1
fi
grep -q 'password authentication failed' "$lab_root/old.err"
printf '%s\n' 'old password rejected after rotation'
PGPASSWORD=LAB-ONLY-NEW-PASSWORD psql -X -qAt -v ON_ERROR_STOP=1 \
  -h 127.0.0.1 -p 55477 -U article_expiry -d postgres \
  -c "SELECT 'new password login='||current_user"
printf "SELECT 'held after rotation='||current_user;\n" >&3
printf '\\q\n' >&3
exec 3>&-
wait "$held_pid"
grep -qx 'held after expiry=article_expiry' "$lab_root/held.out"
grep -qx 'held after rotation=article_expiry' "$lab_root/held.out"
cat "$lab_root/held.out"

The recorded PostgreSQL 18.6 run reports that the deadline has passed. It waits on the database clock after setting a deadline one second ahead, so this is an actual expiry crossing rather than merely a string comparison against a fabricated date. A fresh SCRAM password login is then rejected.

The connection opened before expiry remains usable. Its input pipe keeps the same client and database session alive across the deadline and password replacement. The final output confirms queries under that role after both events. Closing or revoking existing sessions requires an explicit policy beyond this password attribute.

A new local trust login succeeds despite the expired password, as the documented authentication distinction predicts. Replacing the password alone still leaves a fresh password login rejected because the old deadline remains. Once the script supplies a new future deadline, the replacement password connects and the original password is rejected.

These checks isolate three independent observations: the deadline affects new password authentication, the chosen authentication method determines whether that rule applies, and existing sessions are not automatically terminated. The example does not implement a production credential broker or demonstrate revocation of every access path. The private cluster and both fixture passwords are discarded afterward.

Refresh before consumers need another connection

Choose lifetimes with enough allowance for obtaining a replacement, distributing it, refreshing consumers, and recovering from a bounded interruption. Monitor remaining validity at the verifier and the age of the value actually loaded by each consumer. An inventory showing a new version is insufficient if a running process still holds the old one.

Validate replacement credentials through fresh authentication before retiring an old version where the platform’s transition model permits overlap. Confirm that retirement denies new use of the old credential, and separately decide whether sessions must be drained or terminated. During a compromise, emergency revocation may take priority over an uninterrupted transition; expiry should not become a reason to wait for the clock.

Keep clocks synchronized and avoid silently extending lifetimes whenever renewal fails. Record which principal, version, verifier, and deadline were tested without recording secret values. The useful evidence is acceptance before the intended boundary, rejection afterward on the intended path, and an understood effect on existing sessions, not just a date displayed in a management interface.

Sources