Skip to content

Webhook HMAC Signature Verification

logi's X-Logi-Signature header comes in two coexisting formats. For which events arrive in which format, see Webhook Integration — request format. An RP verifier must accept both formats.

FormatExample header valueSourceSecret
Legacysha256=a3d9f0...WebhookDispatchJob (the original 4 events)A single webhook_secret
PLAN-L (canonical)t=1735000000,kid=whk_2025q4_a1,v1=a3d9f0...Logi::Webhooks::DeliveryJob (user.merged and other new events)The key looked up by kid

Both formats are an HMAC-SHA256 over the body, but the timestamp is in a different place — legacy uses a separate X-Logi-Timestamp header, while PLAN-L uses the t= field inside the signature header.

Verification order

  1. Branch on the X-Logi-Signature format
    • If the value contains , or t= → parse it as PLAN-L (t=…,kid=…,v1=…)
    • If it starts with sha256= → legacy
  2. Check the timestamp range — within ±5 minutes of the current time (replay defense)
    • PLAN-L: the t= value
    • Legacy: the X-Logi-Timestamp header value
  3. Recompute the signature, then compare in constant time
    • PLAN-L: look up the key by kidHMAC-SHA256(secret_for_kid, raw_body) → compare with v1=
    • Legacy: HMAC-SHA256(webhook_secret, raw_body) → compare with the hex in sha256=

A verifier that supports both formats

ts
import crypto from "node:crypto";

// keyResolver(kid) → a Buffer/Uint8Array secret. If you run a single key,
// you can ignore the kid with something like `(kid) => process.env.LOGI_WEBHOOK_SECRET`.
export function verifyLogiWebhook(req, { legacySecret, keyResolver }) {
  const sigHeader = req.header("X-Logi-Signature") ?? "";
  const raw = req.rawBody;  // ← must be the raw bytes, before parsing

  // 1. Format split
  if (sigHeader.includes(",") || sigHeader.startsWith("t=")) {
    // PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
    const fields = Object.fromEntries(
      sigHeader.split(",").map(p => p.split("=").map(s => s.trim()))
    );
    if (!fields.t || !fields.kid || !fields.v1) throw new Error("malformed sig");
    if (Math.abs(Date.now() / 1000 - Number(fields.t)) > 300) throw new Error("replay");

    const secret = keyResolver(fields.kid);
    if (!secret) throw new Error(`unknown kid: ${fields.kid}`);

    const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
    const a = Buffer.from(fields.v1, "hex"), b = Buffer.from(expected, "hex");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig");
    return;
  }

  // Legacy: sha256=<hex> + X-Logi-Timestamp header
  const ts = Number(req.header("X-Logi-Timestamp") ?? "0");
  if (Math.abs(Date.now() / 1000 - ts) > 300) throw new Error("replay");

  const sig = sigHeader.replace("sha256=", "");
  const expected = crypto.createHmac("sha256", legacySecret).update(raw).digest("hex");
  const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig");
}
ruby
require "openssl"

def verify_logi!(request, legacy_secret:, key_resolver:)
  sig_header = request.headers["X-Logi-Signature"].to_s
  raw = request.raw_post

  if sig_header.include?(",") || sig_header.start_with?("t=")
    # PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
    fields = sig_header.split(",").to_h { |p| p.split("=", 2).map(&:strip) }
    raise "malformed sig" unless fields["t"] && fields["kid"] && fields["v1"]
    raise "replay" if (Time.current.to_i - fields["t"].to_i).abs > 300

    secret = key_resolver.call(fields["kid"])
    raise "unknown kid: #{fields['kid']}" unless secret

    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw)
    raise "bad sig" unless ActiveSupport::SecurityUtils.secure_compare(fields["v1"], expected)
    return
  end

  # Legacy
  ts = request.headers["X-Logi-Timestamp"].to_i
  raise "replay" if (Time.current.to_i - ts).abs > 300

  sig = sig_header.sub("sha256=", "")
  expected = OpenSSL::HMAC.hexdigest("SHA256", legacy_secret, raw)
  raise "bad sig" unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)
end
python
import hmac, hashlib, time

def verify_logi(headers, raw_body, legacy_secret, key_resolver):
    sig_header = headers.get("X-Logi-Signature", "")

    if "," in sig_header or sig_header.startswith("t="):
        # PLAN-L: t=<ts>,kid=<kid>,v1=<hex>
        fields = dict(part.strip().split("=", 1) for part in sig_header.split(","))
        if not all(k in fields for k in ("t", "kid", "v1")):
            raise ValueError("malformed sig")
        if abs(time.time() - int(fields["t"])) > 300:
            raise ValueError("replay")

        secret = key_resolver(fields["kid"])
        if not secret:
            raise ValueError(f"unknown kid: {fields['kid']}")

        expected = hmac.new(secret.encode() if isinstance(secret, str) else secret,
                            raw_body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(fields["v1"], expected):
            raise ValueError("bad sig")
        return

    # Legacy
    ts = int(headers.get("X-Logi-Timestamp", "0"))
    if abs(time.time() - ts) > 300:
        raise ValueError("replay")
    sig = sig_header.replace("sha256=", "")
    expected = hmac.new(legacy_secret.encode(), raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise ValueError("bad sig")

Important: always verify against the raw body (the original bytes, before JSON parsing). Serializing again after parsing changes whitespace and ordering, which breaks the signature.

kid key lookup (PLAN-L only)

The kid in the PLAN-L format is the identifier of a rotatable webhook signing key in logi. An RP maintains the kid → secret mapping in one of two ways.

  • Polling: call GET /api/v1/webhook_signing_keys periodically and cache the active key plus any old keys still within the grace window.
  • Push: re-fetch immediately on a webhook_key.compromised event.

For the rotation procedure and grace window, see Webhook Key Rotation.

Legacy single-secret rotation

The legacy path rotates a single webhook_secret without a key ID.

bash
# rotate
PAK="logi_pak_..."
curl -X POST -H "Authorization: Bearer $PAK" \
  https://api.1pass.dev/api/v1/applications/$APP_ID/rotate_webhook_secret \
  | jq -r '.webhook_secret'
# → the plaintext is printed once. Store it in your secret manager immediately.

After rotating, replace the legacySecret value in the verification code above with the new plaintext. The new signature applies immediately from the moment of rotation, with no grace window — if you want both secrets to be valid briefly right after rotation, we recommend migrating to the PLAN-L outbox path.

Handling the deprecation header

If an existing app has not rotated its webhook secret, logi signs with a BCrypt fallback (legacy path only). In that case, the following headers are sent along:

http
X-Logi-Secret-Deprecated: true
Deprecation: @1925000000
  • We recommend the receiving side log this at WARN level — verification still works, but since it isn't a stable plaintext, the signature can't be reproduced.
  • Call rotate_webhook_secret from the developer portal right away → store the plaintext returned in the response in an environment variable, then verify with it in your code.

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