Authenticating inbound agent webhooks in Go: constant-time bearer token, no timing leak, async 202 dispatch
How to accept an inbound HTTP trigger from another agent or harness in Go without a timing side-channel, an unauthenticated open door, or a request that blocks while the pipeline runs.
Last tested: June 2026. See Changelog at the bottom.
If this saves you from a
==token check, follow @renezander030 — production notes on wiring agents together safely.Working implementation: github.com/renezander030/draftcat (Go, MIT) — the webhook handler, constant-time auth, and the tests below all ship in the repo.
The setup: agent A finishes, and wants agent B (a draftcat pipeline) to pick up the work. B exposes an HTTP endpoint. Three things go wrong if you write the obvious handler:
- You compare the token with
==, which returns early on the first mismatched byte and leaks the secret one character at a time under timing analysis. - You forget to refuse startup when the secret env var is empty, so a deploy with a missing secret silently serves an open trigger.
- You run the pipeline inside the request, so the caller blocks for 30 seconds and retries, double-firing the work.
Here is the handler that fixes all three.
| Concern | Do this |
|---|---|
| Compare the token | crypto/subtle.ConstantTimeCompare, never == |
| Hold the secret | env var (DRAFTCAT_WEBHOOK_SECRET), resolved at runtime, never from YAML |
| Empty secret | refuse to start — log.Fatalf, do not serve an open trigger |
| Send work | Bearer <secret> in the Authorization header |
| Respond | 202 Accepted immediately, run the pipeline in a goroutine |
| Cap the body | io.LimitReader(r.Body, 65536) |
| Stop double-fires | a scheduler TryStart that returns 409 Conflict if already running |
The rule of thumb: authenticate in constant time, fail closed on a missing secret, and answer 202 before you do the work. A webhook that blocks is a webhook that gets retried.
git clone https://github.com/renezander030/draftcat && cd draftcat
export DRAFTCAT_WEBHOOK_SECRET=s3cret
go test -run TestWebhook -v . # exercises auth, method, 404, 409, 202 pathsTrigger a pipeline from the other agent:
curl -X POST https://your-host/hooks/ping \
-H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" \
-d '{"hello":"world"}'
# > 202 acceptedThe whole auth check. Two parts: confirm the Bearer prefix, then compare the rest against the secret in constant time so a near-correct guess takes exactly as long as a wildly wrong one.
import "crypto/subtle"
secret := []byte(cfg.Webhook.Secret()) // resolved from env at startup
mux.HandleFunc("/hooks/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
const prefix = "Bearer "
got := r.Header.Get("Authorization")
if !strings.HasPrefix(got, prefix) ||
subtle.ConstantTimeCompare([]byte(strings.TrimPrefix(got, prefix)), secret) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// ... authenticated past here
})subtle.ConstantTimeCompare returns 1 only when the two byte slices are equal, and crucially does not short-circuit on the first differing byte. A plain string(got) == secret does, and that timing difference is measurable over enough requests.
The secret never comes from YAML (it would land in your repo). It is resolved from an env var at boot, and if the endpoint is enabled with no secret, the process refuses to start rather than serve an unauthenticated trigger:
if cfg.Webhook.Enabled && resolvedSecret == "" {
log.Fatalf("webhook.enabled is true but secret env %q is empty — "+
"refusing to start an unauthenticated trigger", cfg.Webhook.SecretEnv)
}Config carries only the name of the env var, never the value:
webhook:
enabled: true
secret_env: DRAFTCAT_WEBHOOK_SECRET # the value is read from the environmentThe handler validates, claims the pipeline, returns 202 Accepted, and dispatches the actual run on a background goroutine. The caller never blocks on the pipeline.
name := strings.TrimPrefix(r.URL.Path, "/hooks/")
p, ok := hookable[name]
if !ok {
http.Error(w, "no webhook-triggerable pipeline named "+name, http.StatusNotFound)
return
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 65536)) // cap the body
if ok, reason := sched.TryStart(name); !ok {
http.Error(w, reason, http.StatusConflict) // already running / paused
return
}
go func(p config.PipelineConfig, body []byte) {
defer sched.SetRunning(p.Name, false)
seed := map[string]interface{}{"webhook_body": string(body)}
if strings.TrimSpace(string(body)) != "" {
seed["input"] = string(body)
}
if err := runPipeline(cfg, p, budget, bot, skills, seed); err != nil {
log.Printf("[webhook] pipeline %s error: %v", p.Name, err)
}
sched.MarkRun(p.Name)
}(p, body)
w.WriteHeader(http.StatusAccepted)
w.Write([]byte("accepted\n"))TryStart is the idempotency guard: a second trigger for a pipeline that is mid-run gets 409 Conflict, not a duplicate execution. The request body is handed to the pipeline as webhook_body / input so the first step can act on the agent's payload.
Only pipelines that opted in with schedule: webhook are reachable; everything else returns 404. A webhook trigger never runs on the timer, and a timer pipeline is never reachable over HTTP.
Every branch has a status code and a test. This is the contract the calling agent codes against:
| Situation | Status | Verbatim message |
|---|---|---|
| Wrong HTTP method (GET) | 405 |
method not allowed |
| Missing or wrong token | 401 |
unauthorized |
| No such webhook pipeline | 404 |
no webhook-triggerable pipeline named <name> |
| Pipeline already running | 409 |
pipeline is already running |
| Pipeline paused | 409 |
pipeline is paused |
| Valid trigger | 202 |
accepted |
$ go test -run TestWebhook -v .
=== RUN TestWebhookRejectsBadMethod
--- PASS: TestWebhookRejectsBadMethod (0.00s)
=== RUN TestWebhookRejectsBadAuth
--- PASS: TestWebhookRejectsBadAuth (0.00s)
=== RUN TestWebhookUnknownPipeline
--- PASS: TestWebhookUnknownPipeline (0.00s)
=== RUN TestWebhookConflictWhenRunning
--- PASS: TestWebhookConflictWhenRunning (0.00s)
=== RUN TestWebhookAcceptsValid
--- PASS: TestWebhookAcceptsValid (0.01s)
PASS
ok github.com/renezander030/draftcat 0.017s
The auth test sends both "" (missing) and Bearer wrong, and asserts both get 401. The conflict test pre-claims the pipeline, then asserts the next trigger gets 409.
draftcat uses a shared bearer token because both ends are yours: agent A holds the secret, B compares it. That is the right call when you control the caller and the transport is TLS.
Reach for an HMAC body signature instead when you cannot trust the caller to keep a static token secret, or you need per-payload integrity (a third-party platform signing each request, e.g. Stripe/GitHub style). The verification shape is the same constant-time compare, just over a computed digest of the body:
import ("crypto/hmac"; "crypto/sha256"; "encoding/hex")
func validSignature(body []byte, header string, secret []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal is itself constant-time
return hmac.Equal([]byte(header), []byte("sha256="+expected))
}Note hmac.Equal is the constant-time comparator here, the same idea as subtle.ConstantTimeCompare. For an internal agent/harness handoff, the bearer token is simpler and sufficient; do not reach for HMAC because it sounds stronger.
401on a token you believe is right > check for a trailing newline in the env var (export X=$(cat file)keeps it). Compare lengths.- Process exits at boot with
refusing to start an unauthenticated trigger>webhook.enabled: truebut thesecret_envvar is empty. Set it or disable the endpoint. 404on a path you expect > the pipeline'sschedule:is notwebhook. Only opted-in pipelines are reachable.409on every call > a previous run never released the flag, or the pipeline is paused. Check the scheduler state.- Caller times out > you are running the pipeline in the request instead of a goroutine. Return
202first.
token == secret— early-return timing leak. Usesubtle.ConstantTimeCompareorhmac.Equal.- Secret in
config.yaml— it ends up in git history. Name the env var in config, read the value from the environment. - No body limit — an unbounded
io.ReadAllis a memory-exhaustion vector. Wrap inio.LimitReader. - Synchronous run — blocks the caller, invites retries and double-fires. Answer 202, work in the background, guard with a run lock.
This is Production AI Automation Notes #13. The series covers approval gates, token budgets, SQLite dedup, prompt-injection defense, deterministic step pipelines, fixture testing, and skills-as-YAML — running LLM agents in production.
- #1 Agent Approval Gates — proposed actions, schema validation, audit log
- #11 Pipeline Fixture Testing — dry-run pipelines from JSON fixtures; zero API calls in CI
- #12 LLM Skills as YAML — prompt + output_schema + role in versioned YAML, validated by a linter
Reference implementation: draftcat (Go, MIT). Follow @renezander030 for new entries.
How do you authenticate agent-to-agent webhooks? Drop a comment with: bearer token vs HMAC vs mTLS, how you store the secret, whether you run sync or async, and the last time a missing-secret deploy bit you.
- Initial publish. Covers constant-time bearer auth, fail-closed on empty secret, async 202 dispatch with a run lock, the full status-code contract with passing tests, the bearer-vs-HMAC decision, debug flow, and setups to avoid.
- Skipped gates: hardware matrix and model-picks table (not hardware- or model-bound — this is an HTTP auth pattern).