---
title: Rotate an OAuth Credential
path: tutorials/rotate-oauth-credentials
status: published
---

# Rotate an OAuth Credential End-to-End

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 `PUT`ing it back.
- A consumer service that reads via cached lookup and invalidates on rotation events.

```mermaid
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
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
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 `PUT`s the new value into ScaiVault. The handler is idempotent (uses the event_id to dedupe).

```python
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
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
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
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](../advanced/webhook-signatures) page with verification recipes for each language.

## 4. Create the rotation policy

```bash
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
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
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. `PUT`s into ScaiVault — new version (v2).
4. Receives `secret.rotated` (from the same webhook) confirming.

Verify in ScaiVault:

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

- [Rotation Policies guide](../api-guides/rotation) — full rotation API.
- [Webhook Signatures](../advanced/webhook-signatures) — the verification details.
- [Migrate from .env files](../tutorials/migrate-from-env-files) — bring more credentials in.
