Rotate an OAuth Credential
A complete walkthrough: take a Salesforce OAuth credential, put it under a 90-day rotation policy with a webhook-driven new-value handler, verify the cycle works, and let it run.
By the end you'll have:
- The credential stored in ScaiVault.
- A rotation policy set to fire every 90 days with a 48h grace period.
- A webhook that handles
rotation.due events by minting a new OAuth credential at Salesforce and PUTing it back.
- A consumer service that reads via cached lookup and invalidates on rotation events.
graph LR
SV[ScaiVault<br/>integrations/salesforce/oauth<br/>+ rotation policy]
Handler[Webhook handler<br/>your service]
SF[Salesforce admin API]
Consumer[Reporting service<br/>in-memory cache]
SV -->|rotation.due| Handler
Handler -->|generate new creds| SF
SF -->|new client_id, client_secret| Handler
Handler -->|PUT new version| SV
SV -->|secret.rotated| Consumer
SV -.read.- Consumer
What you need
- A ScaiVault token with
secrets:read, secrets:write, secrets:rotate, and admin scopes (for creating the rotation policy and webhook).
- A reachable HTTPS endpoint to receive webhooks. Use a tunneling tool like
cloudflared or ngrok in development.
- Salesforce admin access — you'll script the new-credential creation against Salesforce's API.
1. Store the current credential
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | curl -X PUT https://scaivault.scailabs.ai/v1/secrets/integrations/salesforce/oauth \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"client_id": "3MVG9...",
"client_secret": "current-secret"
},
"secret_type": "json",
"metadata": {
"description": "Salesforce OAuth for production integrations",
"tags": ["salesforce", "oauth", "production"],
"owner": "team:integrations"
},
"options": {
"max_versions": 10
}
}'
|
Confirm:
| curl -H "Authorization: Bearer $TOKEN" \
https://scaivault.scailabs.ai/v1/secrets/integrations/salesforce/oauth \
| jq '.version, .metadata.tags'
|
2. Stand up the rotation webhook receiver
A minimal FastAPI service. It receives rotation.due events, regenerates the Salesforce OAuth credential, and PUTs the new value into ScaiVault. The handler is idempotent (uses the event_id to dedupe).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57 | import hmac, hashlib, os, time
import httpx
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = os.environ["SV_WEBHOOK_SECRET"].encode()
SCAIVAULT_TOKEN = os.environ["SCAIVAULT_TOKEN"]
seen_events: set[str] = set()
@app.post("/scaivault/rotate-sf-oauth")
async def handle(request: Request):
body = await request.body()
timestamp = request.headers.get("X-ScaiVault-Timestamp", "")
sig = request.headers.get("X-ScaiVault-Signature", "")
# Verify signature (see Webhook Signatures guide)
if not timestamp or not sig.startswith("sha256="):
raise HTTPException(400)
if abs(int(time.time()) - int(timestamp)) > 300:
raise HTTPException(401, "stale")
expected = hmac.new(WEBHOOK_SECRET, f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig[7:]):
raise HTTPException(401, "bad sig")
event = await request.json()
if event["event_id"] in seen_events:
return {"ok": True, "duplicate": True}
seen_events.add(event["event_id"])
if event["event_type"] != "rotation.due":
return {"ok": True, "ignored": event["event_type"]}
if not event["data"].get("due_now"):
# 7d / 1d warnings — log, don't act
print(f"[warn] {event['path']} rotates at {event['data']['next_rotation_at']}")
return {"ok": True}
# Generate the new credential in Salesforce
new_creds = mint_new_salesforce_oauth()
# Write it back
r = httpx.put(
f"https://scaivault.scailabs.ai/v1/secrets/{event['path']}",
headers={"Authorization": f"Bearer {SCAIVAULT_TOKEN}"},
json={
"data": new_creds,
"secret_type": "json",
},
)
r.raise_for_status()
return {"ok": True, "new_version": r.json()["version"]}
def mint_new_salesforce_oauth() -> dict:
"""Call Salesforce admin API to create new OAuth credentials.
Implementation depends on your SF setup."""
raise NotImplementedError
|
Run it locally and tunnel:
| SV_WEBHOOK_SECRET=$(openssl rand -hex 32) \
uvicorn handler:app --port 8000 &
cloudflared tunnel --url http://localhost:8000
# -> https://random-name.trycloudflare.com
|
3. Register the webhook in ScaiVault
1
2
3
4
5
6
7
8
9
10
11
12
13 | WEBHOOK_URL="https://random-name.trycloudflare.com/scaivault/rotate-sf-oauth"
curl -X POST https://scaivault.scailabs.ai/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"sf-oauth-rotator\",
\"url\": \"$WEBHOOK_URL\",
\"secret\": \"$SV_WEBHOOK_SECRET\",
\"events\": [\"rotation.due\", \"secret.rotated\"],
\"filters\": {\"path_prefix\": \"integrations/salesforce/\"}
}"
# -> {"id": "wh_abc", ...}
|
Smoke-test the webhook plumbing before going further:
| curl -X POST https://scaivault.scailabs.ai/v1/webhooks/wh_abc/test \
-H "Authorization: Bearer $TOKEN"
# -> {"delivery_id": "...", "status": "success", "response_code": 200}
|
If status isn't success, fix it now — check the URL, the tunnel, and signature verification in your handler. The agent guide has a Webhook Signatures page with verification recipes for each language.
4. Create the rotation policy
1
2
3
4
5
6
7
8
9
10
11
12
13 | curl -X POST https://scaivault.scailabs.ai/v1/rotation/policies \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "oauth-90d",
"description": "90-day rotation for OAuth credentials",
"interval": "90d",
"grace_period": "48h",
"warn_before": "7d,1d",
"auto_generate": false,
"webhook_ids": ["wh_abc"]
}'
# -> {"id": "rot_oauth-90d", ...}
|
auto_generate: false is deliberate: ScaiVault won't generate the new OAuth credential itself (Salesforce owns the key material). It fires the event; your webhook does the work.
5. Assign the secret to the policy
| curl -X POST https://scaivault.scailabs.ai/v1/rotation/policies/rot_oauth-90d/secrets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"secret_path": "integrations/salesforce/oauth"}'
|
6. Test the full cycle with a forced rotation
Don't wait 90 days. Trigger it manually:
| curl -X POST https://scaivault.scailabs.ai/v1/rotation/policies/rot_oauth-90d/rotate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"secret_paths": ["integrations/salesforce/oauth"]}'
|
Watch your handler logs. Expected:
- Receives
rotation.due with due_now: true.
- Calls Salesforce, gets back new credentials.
PUTs into ScaiVault — new version (v2).
- Receives
secret.rotated (from the same webhook) confirming.
Verify in ScaiVault:
| curl -H "Authorization: Bearer $TOKEN" \
https://scaivault.scailabs.ai/v1/secrets/integrations/salesforce/oauth/versions
# -> versions [{version:2, is_current:true}, {version:1, is_current:false}]
|
The previous version (v1) stays readable until the 48h grace window closes. Any consumer mid-flight with v1 cached can still finish.
7. Wire the consumer cache to invalidate on rotation
The reporting service that uses this credential. In-memory cache with a 5-minute TTL, plus a webhook handler that invalidates on secret.rotated events for this path:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | import time, httpx
_cache = {"value": None, "expires_at": 0}
def get_salesforce_creds() -> dict:
now = time.time()
if _cache["value"] is None or now > _cache["expires_at"]:
r = httpx.get(
"https://scaivault.scailabs.ai/v1/secrets/integrations/salesforce/oauth",
headers={"Authorization": f"Bearer {TOKEN}"},
)
r.raise_for_status()
_cache["value"] = r.json()["data"]
_cache["expires_at"] = now + 300
return _cache["value"]
# In your webhook receiver:
def on_event(event):
if event["event_type"] == "secret.rotated" and event["path"] == "integrations/salesforce/oauth":
_cache["value"] = None # force refresh on next read
|
You could subscribe this consumer separately, or reuse the same webhook by adding the consumer's URL as a second delivery target.
8. Verify the audit trail
| curl -H "Authorization: Bearer $TOKEN" \
"https://scaivault.scailabs.ai/v1/audit/secrets/integrations/salesforce/oauth"
|
You should see rows for:
- The original
write (v1).
- Your forced rotation:
rotate action.
- The
write (v2) from your handler.
- Whatever reads have happened since.
The trail is now the canonical history for this credential.
What you have now
- Rotation will fire on its 90-day cadence.
- 7 days before each rotation, your webhook fires a "due" event you can use for monitoring or human review.
- 1 day before, another reminder.
- On rotation day, the new OAuth credential is minted and stored without human intervention.
- The grace period gives consumers 48 hours to pick up the new value.
- Any consumer subscribed for
secret.rotated invalidates its cache within seconds.
Edge cases worth considering
- What if Salesforce is down when rotation fires? The webhook handler should return non-2xx; ScaiVault will retry on its exponential backoff (30s, 5min, 30min, 2h, 8h, 24h). Eventually
rotation.failed fires — wire that into alerting.
- What if your handler succeeds but the
PUT to ScaiVault fails? You've burned a Salesforce credential without storing it. Two options: (a) make the handler idempotent on the SF side so retries are safe, (b) on PUT failure, immediately call Salesforce to deactivate the just-minted credential.
- What if a consumer holds an old version past grace? It'll get
secret_expired on the next read (or version_not_found if reading explicit version). Reading the current path always returns the current version, so consumers using the standard pattern won't see this.
What's next