Webhook Signing Key Rotation
Operators only
This page is for logi operators (the infrastructure/security staff who run the key rotation rake tasks directly).
RP integration developers only need to know these four things:
- logi rotates the webhook signing key (on a schedule, or immediately on exposure).
- The RP periodically polls the list of active keys via
GET /api/v1/webhook_signing_keys. - Cache multiple keys at once, and look them up by the
kidof the incoming webhook. - On a
kidmiss, force a one-time refetch.
For the full RP-side implementation, see Webhook HMAC Signature Verification. The operational procedures below (rake tasks, compromise flow, etc.) are not needed for the RP-side implementation.
logi's webhook_signing_keys table is designed so that a single RP can hold multiple active keys at the same time (for zero-downtime rotation and exposure response). This page covers the procedures you follow when rotating, revoking, or doing an emergency replacement of a key.
When to rotate
| Trigger | Recommended frequency/response | revoke_reason |
|---|---|---|
| Preventive (periodic) | Once a quarter or every 90 days | rotation |
| Operator departure / credential revocation | Immediately | admin |
| Suspected secret exposure (CI logs, ticket attachments, etc.) | Immediately + skip grace | compromise |
| Cleanup after the grace window expires | Cleanup after 24h | rotation_grace_expired |
The revoke_reason value is enforced by the server's WebhookSigningKey::REVOKE_REASONS constant. Do not put arbitrary values in it from external operational tools.
Rotation mechanism
logi performs rotation via rake tasks. They run in the Render shell / local console, not in the CLI or admin UI:
# 1) Issue a new key — the existing key stays valid
bin/rails 'webhooks:rotate[<oauth_app_id>]'
# → Issued new signing key: kid=ab12cd34
# secret=<plaintext> ← printed only once. Hand it off immediately via a secret manager or a secure channelFrom this point on:
- The signature on new outbox rows is computed with the most recent unrevoked key (
WebhookSigningKey.active_for). - Rows already waiting to be delivered keep their own
signature_kid— the RP must verify them with thatkid. - The existing key stays valid until you revoke it — giving the RP time to pull the new
kid(GET /api/v1/webhook_signing_keys).
Once you judge that the RP has caught up, revoke the old key:
bin/rails 'webhooks:revoke[<oauth_app_id>,<old_kid>]'webhooks:revoke refuses to revoke the "last active key" — if you try to revoke before rotating, it stops immediately:
Refusing to revoke last active key — run webhooks:rotate first.What the grace window means
The DB schema comment assumes a revoked_at = now + 24h pattern (scheduling a revoke for a future time). The model's usable scope is also revoked_at IS NULL OR revoked_at > now — that is, a future-dated revoke still passes verification during the grace window. The current webhooks:revoke rake task revokes immediately with Time.current. If you need a 24h grace, run it as an operational pattern: rotate, wait 24h, then call revoke.
RP-side response
The RP must periodically fetch the list of active keys via GET /api/v1/webhook_signing_keys. Auth is HTTP Basic + client_id:client_secret.
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
https://api.1pass.dev/api/v1/webhook_signing_keysResponse shape:
{
"keys": [
{
"kid": "ab12cd34",
"secret": "<hex>",
"algorithm": "HMAC-SHA256",
"issued_at": "2026-05-11T10:00:00Z",
"expires_at": null,
"revoked_at": null,
"revoke_reason": null
}
]
}Requirements for the RP implementation:
- Cache multiple keys at once — hold both the new and old keys. If you only keep a single key, verification fails the moment a rotation happens.
- Look up by
kid— parsekid=<value>from the incoming webhook'sX-Logi-Signatureheader, and look up the matching key in the cache (the step before comparingv1=<hmac>). - Cache refresh interval — at least once every 5 minutes. On a
kidmiss, force a refetch immediately (only once — for the case where the cache is stale right after a rotation). - Clean up expired keys — remove keys whose
revoked_atis in the past from the cache immediately.
Emergency rotation (compromise)
If you suspect a secret has been exposed, replace it immediately, with no grace window. A single rake task handles it atomically:
bin/rails 'webhooks:compromise[<oauth_app_id>,<compromised_kid>]'Within a single DB transaction, this task does the following:
- Issues a new key first (in case the compromised key was the only active one).
- Hard-revokes the compromised key immediately, setting
revoked_at = Time.current,revoke_reason = "compromise". - Re-signs with the new key every not-yet-delivered outbox row (
delivered_at IS NULL AND dlq_at IS NULL) whosesignature_kidis the compromised one. - Records a
webhook_key.compromisedevent in the outbox — the RP is notified via webhook.
webhook_key.compromised event payload:
{
"revoked_kid": "ab12cd34",
"revoked_at": "2026-05-11T10:30:00Z",
"reason": "compromise",
"active_kids": ["ef56gh78"]
}An RP that receives this event must immediately clear its cache and refetch /api/v1/webhook_signing_keys. Note, however, that this notification event itself is signed with the new key, so if the RP has not yet received the new key, verification fails — notify them simultaneously through an out-of-band channel (Slack/email).
Out-of-band notification
On a compromise, it is safer to also notify the RP's operators through a channel other than the webhook. logi does not currently run any automatic notification channel other than the webhook itself — define a procedure in advance for the operator to contact the RP's integration owner directly.
Audit
The rotate, revoke, and compromise operations are all recorded in Rails.logger with the following prefixes:
[webhooks:rotate] app_id=<id> new_kid=<kid>[webhooks:revoke] app_id=<id> kid=<kid>[webhooks:compromise] app_id=<id> revoked_kid=<kid> new_kid=<kid> resigned=<n>(WARN level)
In the Render log, search the prefix [webhooks: to see all key lifecycle events on one screen. Attach these to the operational log when you do the quarterly rotation.
Rotation checklist
- [ ] Right after issuing the new key, the plaintext was handed off to the RP operator through a secure channel.
- [ ] Confirmed the new
kidappears in the RP's pull from/api/v1/webhook_signing_keys. - [ ] After issuing the new key, at least one real webhook was signed with the new
kidand the RP verified it successfully. - [ ] (For periodic rotation) Revoked the old key after at least 24h of grace elapsed.
- [ ] (For a compromise) The
webhook_key.compromisedevent reached every active RP + the out-of-band notification was completed. - [ ] Checked the
[webhooks:*]lines in the Render log and attached them to the operational log.