Skip to content

Instantly share code, notes, and snippets.

@renezander030
Created June 15, 2026 18:21
Show Gist options
  • Select an option

  • Save renezander030/26d46d4c7fb9ab1b43fe19bc5bad6d07 to your computer and use it in GitHub Desktop.

Select an option

Save renezander030/26d46d4c7fb9ab1b43fe19bc5bad6d07 to your computer and use it in GitHub Desktop.
Authenticating inbound agent webhooks in Go: constant-time bearer token, no timing leak, async 202 dispatch

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:

  1. 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.
  2. You forget to refuse startup when the secret env var is empty, so a deploy with a missing secret silently serves an open trigger.
  3. 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.

TL;DR cheat sheet

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.

Recommended setup

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 paths

Trigger a pipeline from the other agent:

curl -X POST https://your-host/hooks/ping \
  -H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" \
  -d '{"hello":"world"}'
# > 202 accepted

1. Constant-time bearer auth

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

2. Fail closed: refuse to start without a secret

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 environment

3. Answer 202, run the work in a goroutine

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

4. Status codes, and the tests that prove them

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.

Bearer token vs HMAC signature: which one you actually need

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.

5. Debug flow

  1. 401 on a token you believe is right > check for a trailing newline in the env var (export X=$(cat file) keeps it). Compare lengths.
  2. Process exits at boot with refusing to start an unauthenticated trigger > webhook.enabled: true but the secret_env var is empty. Set it or disable the endpoint.
  3. 404 on a path you expect > the pipeline's schedule: is not webhook. Only opted-in pipelines are reachable.
  4. 409 on every call > a previous run never released the flag, or the pipeline is paused. Check the scheduler state.
  5. Caller times out > you are running the pipeline in the request instead of a goroutine. Return 202 first.

Setups to avoid

  • token == secret — early-return timing leak. Use subtle.ConstantTimeCompare or hmac.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.ReadAll is a memory-exhaustion vector. Wrap in io.LimitReader.
  • Synchronous run — blocks the caller, invites retries and double-fires. Answer 202, work in the background, guard with a run lock.

Series

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.

Reference implementation: draftcat (Go, MIT). Follow @renezander030 for new entries.

Reader contributions

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.

Changelog

2026-06-15

  • 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment