Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

bash
 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:

bash
1
2
3
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).

python
 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:

bash
1
2
3
4
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#

bash
 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:

bash
1
2
3
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#

bash
 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#

bash
1
2
3
4
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:

bash
1
2
3
4
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:

  1. Receives rotation.due with due_now: true.
  2. Calls Salesforce, gets back new credentials.
  3. PUTs into ScaiVault — new version (v2).
  4. Receives secret.rotated (from the same webhook) confirming.

Verify in ScaiVault:

bash
1
2
3
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:

python
 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#

bash
1
2
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#

Updated 2026-05-17 14:30:20 View source (.md) rev 4