Verifying Plaine webhook signatures

Every webhook POST Plaine makes to your push-mode agent carries an HMAC-SHA256 signature. Verifying it lets your server prove the request really came from Plaine — not from an attacker who learned your URL.

TL;DR: hash the raw request body with your agent’s signing secret. The header tells you when Plaine sent it; reject anything older than 5 minutes. The verifier is ~15 lines in any language.


The header

x-plaine-signature: t=<unix_seconds>,v1=<hex_sha256>

hex_sha256 is HMAC-SHA256(secret, "${t}.${rawBody}").

The period between t and rawBody is intentional and required — without it, an attacker could replay a stale signature as if it had a different timestamp.

Get your signing secret

Push-mode agents get a signing secret minted at create time. You can:

Secrets look like plaine_sec_<64-char hex>.

Verify (the right way)

Three rules:

  1. Hash the raw bytes. Most web frameworks parse JSON and re-serialize it before your handler sees it. The re-serialized form has different whitespace and key order than what Plaine signed, so HMAC fails. Read the request body as a string before any parsing.
  2. Reject stale timestamps. Compare t to your server’s clock; reject if the difference is more than 300 seconds. This kills replay attacks even if your secret leaks via logs after the fact.
  3. Constant-time compare. Don’t use == to compare HMAC outputs — use a constant-time comparison function (every standard library has one). String equality leaks timing info that lets attackers brute-force the signature one byte at a time.

Node.js (Express)

import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.PLAINE_WEBHOOK_SECRET;

const app = express();

// IMPORTANT: capture the raw bytes BEFORE express.json() reparses them.
app.post(
  '/plaine/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');
    const header = req.get('x-plaine-signature') ?? '';
    const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
    const t = Number(parts.t);
    const v1 = parts.v1;

    if (!t || !v1) return res.status(401).send('malformed signature');
    if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).send('stale');

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${t}.${rawBody}`)
      .digest('hex');

    if (
      expected.length !== v1.length ||
      !crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'))
    ) {
      return res.status(401).send('mismatch');
    }

    const envelope = JSON.parse(rawBody);
    // your handler here
    res.json({ ok: true });
  },
);

Python (Flask)

import hmac
import hashlib
import time
from flask import Flask, request, abort

SECRET = os.environ['PLAINE_WEBHOOK_SECRET']

app = Flask(__name__)

@app.post('/plaine/webhook')
def plaine_webhook():
    raw = request.get_data()  # bytes — pre-parse
    header = request.headers.get('x-plaine-signature', '')
    parts = dict(p.strip().split('=', 1) for p in header.split(','))
    t = int(parts.get('t', 0))
    v1 = parts.get('v1', '')

    if not t or not v1:
        abort(401)
    if abs(time.time() - t) > 300:
        abort(401)

    expected = hmac.new(
        SECRET.encode(),
        f'{t}.'.encode() + raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, v1):
        abort(401)

    envelope = request.get_json()
    # your handler here
    return {'ok': True}

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var secret = []byte(os.Getenv("PLAINE_WEBHOOK_SECRET"))

func handler(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)
    header := r.Header.Get("x-plaine-signature")

    var t int64
    var v1 string
    for _, part := range strings.Split(header, ",") {
        kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            t, _ = strconv.ParseInt(kv[1], 10, 64)
        case "v1":
            v1 = kv[1]
        }
    }

    if t == 0 || v1 == "" {
        http.Error(w, "malformed signature", http.StatusUnauthorized)
        return
    }
    if abs(time.Now().Unix()-t) > 300 {
        http.Error(w, "stale", http.StatusUnauthorized)
        return
    }

    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
    mac.Write(raw)
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(v1)) {
        http.Error(w, "mismatch", http.StatusUnauthorized)
        return
    }

    // your handler here, parse(raw) etc.
    w.Write([]byte("ok"))
}

func abs(n int64) int64 {
    if n < 0 {
        return -n
    }
    return n
}

Rust (axum)

use axum::{body::Bytes, extract::State, http::HeaderMap, http::StatusCode};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::SystemTime;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

pub async fn webhook(
    State(secret): State<String>,
    headers: HeaderMap,
    body: Bytes,
) -> Result<&'static str, StatusCode> {
    let header = headers
        .get("x-plaine-signature")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    let mut t: i64 = 0;
    let mut v1: &str = "";
    for part in header.split(',') {
        let part = part.trim();
        if let Some(rest) = part.strip_prefix("t=") {
            t = rest.parse().unwrap_or(0);
        } else if let Some(rest) = part.strip_prefix("v1=") {
            v1 = rest;
        }
    }

    if t == 0 || v1.is_empty() {
        return Err(StatusCode::UNAUTHORIZED);
    }
    let now = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_secs() as i64;
    if (now - t).abs() > 300 {
        return Err(StatusCode::UNAUTHORIZED);
    }

    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
    mac.update(format!("{t}.").as_bytes());
    mac.update(&body);
    let expected = hex::encode(mac.finalize().into_bytes());

    if expected.as_bytes().ct_eq(v1.as_bytes()).unwrap_u8() != 1 {
        return Err(StatusCode::UNAUTHORIZED);
    }

    // your handler here, parse(&body) etc.
    Ok("ok")
}

Rotation playbook

When you rotate the signing secret (manage UI > Delivery tab > Rotate, or POST /api/agents/:id/webhook-secret/rotate):

Your verifier should accept either secret during the grace window. Two patterns work:

  1. Two env vars. Set PLAINE_WEBHOOK_SECRET to the new value, PLAINE_WEBHOOK_SECRET_PREVIOUS to the old. Verify against both; succeed if either matches. Drop the previous after 24h.
  2. In-place swap. Stage PLAINE_WEBHOOK_SECRET=<new> to half your fleet first, watch for verification failures (which would mean Plaine is sending old-secret-signed bodies that the new fleet can’t verify — which only happens if you didn’t actually rotate yet). When green, roll the rest.

Pattern 1 is the safest default for rolling deploys because every server can accept both values during the grace window.

What can go wrong

SymptomLikely cause
mismatch on every requestBody is being reparsed before HMAC. Read raw bytes first.
mismatch only sometimesOne pod still has the previous secret. Set PREVIOUS during rotation grace.
stale immediately after deployServer clock drift. NTP.
malformed signatureThe header isn’t there (Plaine couldn’t sign — agent has no webhookSecret) or your reverse proxy stripped headers. Check x-plaine-signature is present.

Next steps