You finished Acme E-commerce 18 months ago. The SSH root@203.0.113.45 + Stripe live key sk_live_... + WordPress admin / ... still sit in Sheets Acme Credentials shared with edit access to two ex-contractors. No rotation log, no updatedAt, no vault — just plaintext cells last touched 2024-09-02. If one contractor's drive leaks, Acme's production is yours to explain.
Agencies rotate logos. They don't rotate secrets. One breach averages $4.45M IBM reports — freelancers won't pay that, but they lose the client, the referral, and reputation. Rotation is not paranoid — it's professional.
Why Freelancers Never Rotate
No trigger. Sheet cell sk_live... lacks updatedAt src/lib/db/schema.ts:137 + category src/lib/db/schema.ts:123 — you never see 180-day stale. No impact link. Stripe Acme in 1Password without clientId/projectId src/lib/db/schema.ts:133 → you can't filter Beta's 8 secrets, so you rotate none. No asset binding. assetsHosting vaultItemId src/lib/db/schema.ts:198 should tie IP+credential; Sheets keeps tabs separate → rotate hunt fails. Leak handover. Email passwords.txt without portalTokens revokedAt src/lib/db/schema-public.ts:175 leaves ex-contractor copy alive.
The Rotation-Ready Vault: Encryption + Linkage + Alerts
RunoSO's vault was built for rotation, not just storage. Three layers:
Layer 1: AES-256-GCM Encryption
encryptGCM src/lib/vault/crypto.ts:22 → deriveKey SHA-256 src/lib/vault/crypto.ts:18 → 12-byte iv → AES-256-GCM → tag getAuthTag → gcm:iv:tag:enc base64 src/lib/vault/crypto.ts:28. decryptGCM verifies tag setAuthTag src/lib/vault/crypto.ts:40 — tamper returns null. All vaultItems usernameEncrypted fields src/lib/db/schema.ts:129 use this; VAULT_MASTER_KEY src/lib/vault/crypto.ts:5 per-tenant — dump is ciphertext.
Layer 2: Linked to Client/Project/Asset
vaultItems {
category password|server|api-key|other src/lib/db/schema.ts:123
name vault name
usernameEncrypted, passwordEncrypted, notesEncrypted src/lib/db/schema.ts:129
url text src/lib/db/schema.ts:131
clientId fk → clients.id src/lib/db/schema.ts:133 // who
projectId fk → projects.id src/lib/db/schema.ts:134 // which scope
metadataEncrypted encrypted JSON src/lib/db/schema.ts:135 // rotation history, provider hints
createdAt, updatedAt timestamp src/lib/db/schema.ts:136 // staleness signal
}
assetsHosting.vaultItemId → vaultItems.id src/lib/db/schema.ts:198
assetsSocial.vaultItemId → vaultItems.id src/lib/db/schema.ts:213
assetsSubscriptions.vaultItemId → vaultItems.id src/lib/db/schema.ts:229Rotate SSH root? Query vaultItems where clientId=Acme + category=server → 3 items sorted by updatedAt. Oldest shows updatedAt 2024-09-02 — 180+ days stale. No 1Password collection gives you that.
Layer 3: Expiry and Handover Signals
assetsDomains expiryDate + assetsHosting renewalDate + assetsSubscriptions renewalDate are already on cron DOMAIN_MILESTONES [14,7,3,1,0] src/app/api/cron/daily/route.ts:16 (daily src/app/api/cron/daily/route.ts:275) → notifications. Six-asset API ALLOWED_CATEGORIES ["domains","cloudflare","github","hosting","social","subscriptions"] src/app/api/assets/[category]/route.ts:91 groups them. Use 7 days window to piggyback rotation. Handover uses portalTokens src/lib/db/schema-public.ts:169.
4-Step Rotation Workflow
1. Inventory — 60s. Filter clientId Acme + updatedAt asc — >90d api-key or >180d server flag stale. 2. Rotate. Generate new key, edit vault → encrypt(text) fresh gcm:iv:tag:enc src/lib/vault/crypto.ts:48, test assetsHosting ipAddress src/lib/db/schema.ts:193, store metadataEncrypted {lastRotated, nextDue}. 3. Revoke. Filter clientId Beta → portalTokens revokedAt now src/lib/db/schema-public.ts:175 — kills old share. 4. Handover. portalTokens tenantId+clientId unique src/lib/db/schema-public.ts:178 shows domains+hosting+github+social names only; rotate server/api-key before sharing — zero lingering access.
Sheets / 1Password / LastPass vs RunoSO Rotation
| Rotation Need | Sheets | 1Password / LastPass | RunoSO Vault + Assets + Cron | |
|---|---|---|---|---|
| Encryption | Plaintext — rotation exposes history | AES-256 but no clientId/projectId | AES-256-GCM gcm:iv:tag:enc src/lib/vault/crypto.ts:22 + deriveKey SHA-256 + clientId/projectId FK | |
| Find stale secrets | Scan Last edited guesses, no category | Sort by modified, filter by tag only | Filter `clientId + category server | api-key + updatedAt sorted src/lib/db/schema.ts:125` — stale list in 1 query |
| Linked impact | No link to hosting IP/repo | No asset link | assetsHosting.vaultItemId + clientId src/lib/db/schema.ts:198 → rotate key → know exactly which ipAddress renewalDate is affected | |
| Renewal cue | None | No domain/hosting cron | Cron daily 03:30 UTC vercel.json:4 → domain 14/7/3/1/0 + renewal 7/3/1/0 → rotation nudge before expiry panic | |
| Handover/revoke | Email passwords.txt forever valid | Psst! link per item, manual per client | portalTokens token revokedAt lastAccessedAt src/lib/db/schema-public.ts:169 → one revocable client bundle | |
| History | Overwrite cell — history lost | History per item, no FK | metadataEncrypted encrypted JSON per vault item → lastRotated, nextDue alongside ciphertext |
Sheets store secrets. Managers encrypt secrets. RunoSO encrypts + links to asset + ages via updatedAt + alerts via renewalDate + revokes via portalTokens — the full rotation loop.
Mistakes That Keep Windows Open
Empty metadataEncrypted loses rotation log — store {lastRotated} encrypted. Changing vault without testing assetsHosting ipAddress+ vaultItemId breaks deploys. Slack after GCM rotation defeats purpose — use portalTokens.
Every secret you created 90 days ago and never touched is stale. Build the 90-day rotation cadence on linked, encrypted vault — not memory.
→ Lock and Rotate in RunoSO — Free
By Gautam Parmar — rotation is ops, not paranoia.
Keep Reading
- Never store there: Where freelancers should never store passwords.
- Hosting twin: Hosting & server management freelancers.
- Zero-knowledge primer: Zero knowledge explained freelancers.
FAQ
How are vault secrets encrypted at rest? encrypt(text) src/lib/vault/crypto.ts:48 derives 32-byte key via SHA-256(VAULT_MASTER_KEY) src/lib/vault/crypto.ts:18, generates 12-byte iv randomBytes, encrypts AES-256-GCM, authenticates via tag getAuthTag, stores gcm:iv:tag:enc base64 src/lib/vault/crypto.ts:28. decryptGCM verifies tag setAuthTag src/lib/vault/crypto.ts:40 — tampered data returns null.
How do I find which credentials belong to a departing client quickly? vaultItems clientId src/lib/db/schema.ts:133 + projectId src/lib/db/schema.ts:134 + assetsHosting.vaultItemId/assetsSocial.vaultItemId. Filter clientId=Beta in vault and assets → 8 items + 2 hosting boxes + 3 socials in one view — rotate only those, not entire vault, unlike flat 1Password list.
How does handover stay secure after rotation? Generate new secrets, update vault (new gcm:iv:tag:enc ciphertext), then share via portalTokens token src/lib/db/schema-public.ts:169 scoped tenantId+clientId unique with revokedAt. Old token revokedAt set instantly invalidates contractor access; new token shows updated bundle — no email plaintext.



