Skip to content

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 kid of the incoming webhook.
  • On a kid miss, 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

TriggerRecommended frequency/responserevoke_reason
Preventive (periodic)Once a quarter or every 90 daysrotation
Operator departure / credential revocationImmediatelyadmin
Suspected secret exposure (CI logs, ticket attachments, etc.)Immediately + skip gracecompromise
Cleanup after the grace window expiresCleanup after 24hrotation_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:

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

From 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 that kid.
  • 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:

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

bash
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  https://api.1pass.dev/api/v1/webhook_signing_keys

Response shape:

json
{
  "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 — parse kid=<value> from the incoming webhook's X-Logi-Signature header, and look up the matching key in the cache (the step before comparing v1=<hmac>).
  • Cache refresh interval — at least once every 5 minutes. On a kid miss, 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_at is 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:

bash
bin/rails 'webhooks:compromise[<oauth_app_id>,<compromised_kid>]'

Within a single DB transaction, this task does the following:

  1. Issues a new key first (in case the compromised key was the only active one).
  2. Hard-revokes the compromised key immediately, setting revoked_at = Time.current, revoke_reason = "compromise".
  3. Re-signs with the new key every not-yet-delivered outbox row (delivered_at IS NULL AND dlq_at IS NULL) whose signature_kid is the compromised one.
  4. Records a webhook_key.compromised event in the outbox — the RP is notified via webhook.

webhook_key.compromised event payload:

json
{
  "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 kid appears 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 kid and 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.compromised event 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.

Identity가 제품의 신뢰를 만듭니다.