Your webhook endpoint is a public URL, so anyone can POST to it. Prove each delivery came from your queue by verifying an HMAC-SHA256 signature: compute the HMAC over the RAW request body bytes (never the re-serialized JSON), compare it to the x-simpleq-signature header with a constant-time comparison to avoid timing attacks, and reject anything that doesn't match with a 401. Use a per-queue signing secret so a leak is contained, and rotate with overlapping secrets so there's no downtime.
When SimpleQ delivers a job, it POSTs to your own worker endpoint — a URL that has to be reachable from the public internet. That's the whole point of a push-based queue: you don't poll, the work comes to you. But a reachable URL is a reachable URL to everyone, not just to your queue. Anyone who learns or guesses the path can POST a forged payload and, if you trust it, make your worker do real work — charge a card, call an LLM, send an email, mutate a database. Signature verification is how you tell a real delivery from a forged one.
This post covers the mechanics end to end: why a public endpoint needs authentication at all, how HMAC-SHA256 works, the x-simpleq-signature header, the two mistakes that quietly break security (parsing the body before hashing it, and comparing with ===), and how per-queue secrets plus rotation keep the blast radius of a leak small. The same patterns apply whether the sender is SimpleQ, Stripe, OpenAI, Anthropic, or any other provider that signs its webhooks.
Why a public endpoint must authenticate every request
A push delivery is just an inbound HTTP POST. Your endpoint cannot tell, from the request alone, whether it came from your queue or from an attacker with curl. Without verification, every one of these is indistinguishable from a legitimate job:
- Forged work. An attacker POSTs a payload that triggers a refund, an outbound email, or an expensive model call.
- Replayed work. Someone captures a real delivery and re-sends it to make your worker run it again.
- Probing. Automated scanners hit the URL with junk to map your API and find error-handling bugs.
Verification doesn't hide the URL or encrypt the payload — it proves authorship. A valid signature means the request was produced by someone holding the shared signing secret. Since only your queue and your endpoint hold that secret, a valid signature is proof the delivery is genuine. An invalid or missing signature gets a 401 before any business logic runs.
IP allowlisting is a defense-in-depth layer, not a substitute for signatures. Provider IP ranges change, and an IP says nothing about whether the body was tampered with in transit. A signature covers the exact bytes of the payload, so it authenticates both the sender and the content.
How HMAC-SHA256 works
HMAC (Hash-based Message Authentication Code) takes two inputs — a message and a secret key — and produces a fixed-size tag. The defining property: you cannot compute the correct tag for a message without the key, and you cannot recover the key from the tag. SHA-256 is the underlying hash, so HMAC-SHA256 produces a 256-bit (32-byte) tag, usually hex-encoded to 64 characters.
The flow for a signed webhook is symmetric:
- 1The sender (your queue) computes
HMAC-SHA256(secret, raw_body)and puts the hex result in a header. - 2The sender POSTs the body and that header to your endpoint.
- 3Your endpoint recomputes
HMAC-SHA256(secret, raw_body)from the body it received, using its copy of the same secret. - 4If the two tags match, the body was produced by someone with the secret and was not modified in transit. If a single byte of the body changed, the recomputed tag won't match.
This is why the secret never travels over the wire on each request — both sides already have it. The header carries only the tag, which is useless to anyone without the key.
The x-simpleq-signature header
SimpleQ signs every webhook delivery with HMAC-SHA256 and sends the result in the x-simpleq-signature header. The signature is computed over the raw request body using the signing secret for the queue that owns the job. Your job is to recompute the same value and compare.
| What | Detail |
|---|---|
| Algorithm | HMAC-SHA256 |
| Header name | x-simpleq-signature |
| Encoding | sha256= prefix + lowercase hex (67 chars total) |
| Signed input | The raw request body bytes, exactly as sent |
| Secret scope | Per queue — each queue has its own signing secret |
| On mismatch | Return 401, do not process the job |
Because the secret is per queue, the verification logic is small and local: look up the secret for the queue that endpoint serves, recompute, compare. If one endpoint serves multiple queues, you select the secret by queue name before verifying.
Verify against the raw body, not the parsed JSON
This is the single most common bug in webhook verification, and it fails silently in the worst way — it usually works in development and breaks in production. HMAC is computed over exact bytes. The sender signed the precise bytes it transmitted. If you parse the JSON and then re-serialize it to hash, you are hashing different bytes:
- Key order changes.
{"a":1,"b":2}and{"b":2,"a":1}parse to the same object but serialize differently. - Whitespace is dropped. The sender may include spaces and newlines that your serializer won't reproduce.
- Numbers and unicode get normalized.
1.0becomes1; escaped unicode may be re-encoded.
Any of these makes the recomputed HMAC differ from the sender's, so verification fails even for legitimate requests — or, worse, you 'fix' it by loosening the check until forged requests slip through. The rule is absolute: capture the raw body bytes before any JSON parsing, run HMAC over those bytes, and only parse the JSON after the signature has passed.
Frameworks like Express install a JSON body parser by default, and by the time your handler runs, req.body is a parsed object and the original bytes are gone. You must configure the parser to keep the raw buffer (e.g. express.json with a verify callback that stashes req.rawBody), or read the raw stream yourself before parsing. In Next.js route handlers, await request.text() gives you the raw body string.
Constant-time comparison and timing attacks
Once you've computed your expected signature, you compare it to the header value. The instinct is expected === received. Don't. String equality short-circuits: it returns the moment it finds the first byte that differs. That tiny difference in execution time leaks information.
An attacker who can send many requests measures how long your endpoint takes to reject each forged signature. A signature that's wrong at byte 1 is rejected fractionally faster than one that's correct through byte 10. By varying bytes and watching the timing, they can recover a valid signature one byte at a time. This is a timing attack, and it's practical against ===.
The fix is a constant-time comparison that always examines every byte regardless of where a mismatch occurs. Node's crypto.timingSafeEqual does exactly this; Python has hmac.compare_digest; Go has hmac.Equal. Use the one for your language — never roll your own equality, and never fall back to === for security-relevant comparisons.
timingSafeEqual throws if the two buffers differ in length. Since HMAC-SHA256 hex is always 64 characters, a length mismatch means the input is malformed — treat that as a verification failure (401) rather than letting the throw become a 500. Convert both sides to Buffers of the expected length before comparing.
A complete verification example
Here is a full verifier in TypeScript using the Web Crypto-friendly Node crypto module. It captures the raw body, recomputes the HMAC, and compares in constant time. Note that it parses JSON only after the signature passes:
1import { createHmac, timingSafeEqual } from "node:crypto";2 3const SIGNING_SECRET = process.env.SIMPLEQ_SIGNING_SECRET!;4 5function verifySignature(rawBody: string, header: string | null): boolean {6 if (!header) return false;7 8 // Recompute the HMAC over the RAW bytes the sender signed. The header is9 // "sha256=<hex>", so prefix the digest to match it exactly.10 const expected =11 "sha256=" +12 createHmac("sha256", SIGNING_SECRET).update(rawBody, "utf8").digest("hex");13 14 const a = Buffer.from(expected, "utf8");15 const b = Buffer.from(header, "utf8");16 17 // timingSafeEqual throws on length mismatch — treat that as a failure.18 if (a.length !== b.length) return false;19 return timingSafeEqual(a, b);20}21 22export async function POST(request: Request) {23 // Read the raw body BEFORE parsing JSON.24 const rawBody = await request.text();25 const signature = request.headers.get("x-simpleq-signature");26 27 if (!verifySignature(rawBody, signature)) {28 return new Response("invalid signature", { status: 401 });29 }30 31 // Safe to parse and act on the payload now.32 const job = JSON.parse(rawBody);33 await handleJob(job);34 35 // ack the job so SimpleQ marks it delivered.36 return new Response("ok", { status: 200 });37}The official SDK, @simpleq/sdk, ships a webhook verifier so you don't hand-write this. You hand it the raw body, the x-simpleq-signature header, and the queue's signing secret; it does the raw-bytes HMAC and the constant-time comparison and throws on failure. The SDK is TypeScript, but since the protocol is plain HMAC-SHA256 over the raw body, the same logic works in any language — hmac.compare_digest in Python, hmac.Equal in Go, OpenSSL::HMAC plus Rack::Utils.secure_compare in Ruby.
1import { verifyWebhook } from "@simpleq/sdk";2 3export async function POST(request: Request) {4 const rawBody = await request.text();5 6 try {7 // Throws if the signature is missing or doesn't match.8 const job = verifyWebhook({9 body: rawBody,10 signature: request.headers.get("x-simpleq-signature"),11 secret: process.env.SIMPLEQ_SIGNING_SECRET!,12 });13 14 await handleJob(job);15 return new Response("ok", { status: 200 });16 } catch {17 return new Response("invalid signature", { status: 401 });18 }19}Per-queue secrets and rotation
A single global signing secret is a single point of failure: leak it once — a log line, a misconfigured env var, a compromised contractor laptop — and an attacker can forge deliveries for everything. SimpleQ signs per queue, so each queue has its own secret. If one leaks, the blast radius is exactly one queue, and you rotate that queue without touching the others.
Rotation should never require downtime. Do it with overlapping secrets:
- 1Provision the new secret for the queue while the old one is still active.
- 2Update the verifier to accept either secret — compute the HMAC twice and pass if either matches (each comparison still constant-time). Deploy this first.
- 3Cut the sender over to the new secret.
- 4Confirm all traffic verifies against the new secret (log which secret matched during the overlap window).
- 5Remove the old secret from the verifier and revoke it.
Rotate on a schedule (quarterly is common) and immediately on any suspected exposure. Store secrets in a secrets manager, never in source control, and scope them so the verifier reads them at runtime rather than baking them into a build.
Verification proves authorship, but a valid request can still arrive more than once — retries and at-least-once delivery are features, not bugs. Make your handler idempotent (key off a job id, or use SimpleQ's idempotencyKey at the publish boundary) so a replayed or redelivered job is safe to process twice. Signatures and idempotency together cover both 'is this real?' and 'have I already done this?'.
Verification checklist
Before you ship a webhook endpoint, confirm every line of this:
- Raw body is captured before any JSON parsing, and HMAC runs over those exact bytes.
- Comparison uses a constant-time function (
timingSafeEqual/compare_digest), never===. - Length mismatch is treated as a failure, not an exception that becomes a 500.
- Missing or malformed signature header returns 401 before any business logic.
- The signing secret comes from a secrets manager, scoped per queue, never committed.
- The handler is idempotent so a verified-but-replayed delivery is safe.
- There's a documented rotation procedure using overlapping secrets.
Get these right once and signature verification becomes invisible infrastructure — a few lines at the top of every handler that quietly turns a public URL into a trusted channel.
SimpleQ signs every delivery with HMAC-SHA256 over the raw body, scopes the signing secret per queue, and ships a verifier in @simpleq/sdk so the constant-time comparison and raw-bytes handling are done for you — POST a job, it's durably stored, and delivered to your own endpoint with a verifiable x-simpleq-signature. Pair it with retries so a transient failure on your side never loses the job: see why job retries matter, and browse the use cases for end-to-end examples.
Frequently asked questions
Ship reliable async work in minutes.
Free tier covers 10,000 job executions a month. No credit card.