Skip to main content

Secret rotation

Why rotate secrets

RiskHow rotation mitigates it
Credential leakA leaked password becomes invalid after the next rotation cycle
Long-lived tokensShorter lifetimes reduce the window of opportunity for an attacker
Former employee accessCredentials rotated after offboarding are useless even if memorized
Compliance requirementsPCI-DSS, SOC 2, and ISO 27001 mandate regular credential changes

Rotation workflow

Order of operations

Always update the target system first, then save to Passwork. If you save to Passwork first and the target system update fails, Passwork holds the new password but the system still uses the old one — your systems are out of sync.

1. Generate new secret

2. Apply to target system (database, API provider, etc.)

3. Verify the new credential works

4. Update Passwork (passwork-cli update / Python SDK)

5. Notify / update downstream consumers

Rotation via CLI

PostgreSQL

#!/bin/bash
set -euo pipefail

PASSWORK_ITEM_ID="<postgres-item-id>"
DB_USER="order_service"
DB_HOST="pg.prod.internal"

# 1. Generate a new password
NEW_PASS=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-32)

# 2. Apply to PostgreSQL
psql -h "$DB_HOST" -U postgres -d postgres \
-c "ALTER ROLE ${DB_USER} WITH PASSWORD '${NEW_PASS}';"

# 3. Update in Passwork
passwork-cli update --password-id "$PASSWORK_ITEM_ID" --password "$NEW_PASS"

echo "Rotated password for ${DB_USER}"

MySQL

#!/bin/bash
set -euo pipefail

PASSWORK_ITEM_ID="<mysql-item-id>"
DB_USER="inventory_svc"

NEW_PASS=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-32)

mysql -h mysql.prod.internal -u root \
-p"$(passwork-cli get --password-id '<mysql-root-item-id>')" \
-e "ALTER USER '${DB_USER}'@'%' IDENTIFIED BY '${NEW_PASS}';"

passwork-cli update --password-id "$PASSWORK_ITEM_ID" --password "$NEW_PASS"

echo "Rotated password for ${DB_USER}"

External API key

#!/bin/bash
set -euo pipefail

PASSWORK_ITEM_ID="<stripe-item-id>"
OLD_KEY=$(passwork-cli get --password-id "$PASSWORK_ITEM_ID")

# 1. Request new key from external service (provider-specific)
NEW_KEY=$(curl -s -X POST "https://api.service.example.com/keys/rotate" \
-H "Authorization: Bearer ${OLD_KEY}" \
-H "Content-Type: application/json" | jq -r '.new_key')

# 2. Verify the new key works
curl -s "https://api.service.example.com/verify" \
-H "Authorization: Bearer ${NEW_KEY}" | grep -q '"status":"ok"'

# 3. Save to Passwork
passwork-cli update --password-id "$PASSWORK_ITEM_ID" --password "$NEW_KEY"

echo "API key rotated"

Rotation via Python SDK

The Python connector gives you full control over the rotation workflow — error handling, logging, transaction semantics, and downstream notifications.

#!/usr/bin/env python3
"""PostgreSQL password rotation with full error handling."""

import os
import secrets
import sys
import psycopg2
from passwork import Client
from passwork.exceptions import PassworkResponseError


def rotate_postgres_password(item_id: str, db_user: str) -> bool:
client = Client(
url=os.environ["PASSWORK_HOST"],
token=os.environ["PASSWORK_TOKEN"],
refresh_token=os.environ.get("PASSWORK_REFRESH_TOKEN"),
)

# Fetch admin credentials from Passwork
admin_item = client.get_password(item_id=os.environ["PG_ADMIN_ITEM_ID"])

new_password = secrets.token_urlsafe(32)

try:
# Apply to PostgreSQL first
conn = psycopg2.connect(
host=admin_item.fields["PG_HOST"],
dbname="postgres",
user=admin_item.login,
password=admin_item.password,
)
conn.autocommit = True
with conn.cursor() as cur:
cur.execute("ALTER ROLE %s WITH PASSWORD %s", (db_user, new_password))
conn.close()

except psycopg2.Error as e:
print(f"Database error — password NOT changed: {e}", file=sys.stderr)
return False

try:
# Only update Passwork after the target system is confirmed updated
item = client.get_password(item_id=item_id)
item.password = new_password
client.update_password(item)

except PassworkResponseError as e:
# Critical: DB password changed but Passwork not updated
# Alert on-call immediately
print(
f"CRITICAL: DB password rotated but Passwork update failed! "
f"API error {e.code}: {e.message}",
file=sys.stderr,
)
return False

print(f"Password rotated for {db_user}")
return True


if __name__ == "__main__":
success = rotate_postgres_password(
item_id=os.environ["TARGET_ITEM_ID"],
db_user=os.environ["PG_DB_USER"],
)
sys.exit(0 if success else 1)

Scheduling rotation

Cron (Linux)

# /etc/cron.d/passwork-rotation
# Rotate production DB passwords every Sunday at 03:00

0 3 * * 0 deploy /opt/scripts/rotate-postgres.sh \
>> /var/log/passwork-rotation.log 2>&1

GitLab CI scheduled pipeline

# .gitlab-ci.yml

rotate_secrets:
stage: maintenance
image: passwork/passwork-cli:latest
script:
- pip install passwork-python
- python /scripts/rotate-all.py
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'

Configure the schedule in CI/CD → Schedules (e.g. every Sunday at 03:00 UTC).


Secret typeFrequency
Production database passwords30–90 days
External API keys90 days or per vendor policy
Service account API tokens (Passwork)30 days
SSH keys6–12 months
TLS certificatesBefore expiry (automate via certbot or ACME)

Handling downstream consumers

When a secret rotates, any service that uses it needs to reload the new value. Common patterns:

Environment variable injection at startup: if services load secrets at startup via passwork-cli exec, they pick up the new value on the next restart. Combine with a rolling restart after rotation.

Sidecar refresh: in Kubernetes, the sidecar pattern (see CI/CD patterns) periodically refreshes the .env file. The app watches for file changes and reloads without restart.

Notification after rotation: in the rotation script, after updating Passwork, send a notification to the relevant Slack channel or trigger a deployment pipeline that restarts the affected services.

For the full rotation reference, see Secret rotation practices.