Blog ·
A webhook URL that is the secret
An incoming webhook can be its own credential if the URL carries the weight. What that costs: 160 bits, a signature over the clock, a body cut off early.
By Provibr
Somebody wants their monitoring tool to start one of our automation flows. The tool can post JSON to a URL, and that is all: no signing, no custom header, no client certificate. It is a box in a rack, configured in 2019, and it will not learn anything new.
You can refuse that customer, or accept that for some sources the URL itself is the credential. We accepted, and then spent the round making sure the URL carries that weight instead of merely being long.
Thirty two characters, and never derived from anything
The slug in /api/hooks/flows/<slug> is thirty two characters from a thirty two symbol alphabet: 160 bits of raw entropy. It is not derived from the account, the flow or the endpoint name, because a slug computable from things a customer already published is computable by anybody who reads their status page.
The alphabet is Crockford base32: the digits and the lowercase letters minus i, l, o and u. Same choice as our agent activation codes, same reason. This string gets typed into somebody else's admin panel, and no typeface makes l and 1 distinguishable when a person is tired.
The secret is encrypted, not hashed
Endpoints can also carry a shared secret, as an HMAC signature or a bearer token. Both store it encrypted, and that is worth explaining, because the instinct built by two decades of password advice is exactly wrong here.
A password can be hashed because you only ever need to answer "is this the same string". An HMAC secret cannot: verification means computing the signature yourself, and for that you need the plaintext. So it goes in under AES-256-GCM, with a key derived from the platform secret and authenticated data naming the endpoint it belongs to, so a ciphertext lifted from one row cannot be verified as another row's. It is the machinery that already protects payment keys and script secrets; a second mechanism would mean a second rotation story and a second place to be wrong.
If that key source ever changes, the ciphertext does not open and the request is refused rather than accepted; the screen then says to regenerate. Failing open on an unreadable secret is the sort of thing you write once, in a hurry, and find three years later.
The customer sees the secret once, at creation. Not because we cannot show it again, but because "written down in two places" and "visible to anybody with automation rights" are different risks and only the first is unavoidable.
The clock goes inside the signed material
The signature is sha256=<hex> in a header, over the timestamp, a dot, and the raw body. The timestamp travels in its own header and has to be within five minutes of our clock, on both sides.
Putting the timestamp inside the signed material rather than beside it is the entire anti replay mechanism. Sign only the body and anyone who captured one valid request can replay it forever with a fresh timestamp header, leaving the window as decoration. Both sides of it matter too: a check that only rejects "too old" accepts a signature dated 2099.
The comparison runs in constant time, because a plain equality check on hex text exits at the first differing character and that timing is enough to guess a signature one byte at a time. And the timestamp is judged after the signature verifies, never before. Reversed, "expired" would be a free oracle: somebody with no valid secret at all could learn that his clock, rather than his signature, was the problem. The distinction between a bad signature and a stale one still exists, but it exists in the receipt the endpoint's owner reads, not in the answer the caller gets.
A body that is cut off while it arrives
The body cap defaults to 64 KiB, and what matters is not the number but where it is enforced.
Reading the request into a string and then checking its length is a check that happens after the damage: a 500 MB body costs us 500 MB before the refusal, which makes the cap an invitation. So the body is read from the stream in chunks, the running total is compared on every chunk, and the reader is cancelled the moment it goes over. The bytes recorded are the bytes we actually read, not the Content-Length the caller claimed, because a caller who is attacking us is exactly the one whose header we should not believe.
The rate limit sits after the body rather than before it, which is the opposite of the usual advice and has to be: the signature is computed over the body, so a limiter answering earlier cannot know whether this request was legitimate. It counts per endpoint rather than per source address. A real webhook source has one address; somebody attacking has a thousand, so a limit on the address protects precisely the wrong party.
Nine refusals, twelve outcomes, and no body anywhere
The order of refusal is the design. Shape of the slug first, before touching the database, because where slug = <one megabyte of text> is work a stranger should not be able to make us do. Then existence, revoked, disabled, the address allowlist, the body cap, the rate limit, the signature, and finally whether the JSON is an object at all.
Every refusal from the third step onward writes a receipt. That table is the answer to "my webhook does nothing", a sentence hiding two different problems: nothing is arriving, or something is arriving and we reject it. Without a row they are indistinguishable, and the customer spends his afternoon on the wrong one.
The receipt carries the outcome as its own word beside the HTTP status, because status codes are a poor language for this: one 403 covers both "your address is not on the list" and "this endpoint is switched off", and those send a person to two different screens. Twelve outcomes, append only, with the source address and the bytes read.
The receipt never carries the body: it is somebody else's payload, it may hold anything, and a debugging convenience is not worth a table that quietly accumulates other people's data. Receipts are kept ninety days, deliberately shorter than the runs they belong to, because a source address is personal data about a third party who never agreed to anything.
One refusal writes nothing: an unknown slug. There is no account to hang the row on, and a table that grows whenever a stranger guesses is a free way to fill our database.
Why 410 and 404 are not the same answer
Revoking an endpoint does not delete it. The row stays, the slug stays taken forever, and a request gets 410 rather than 404. The name becomes reusable, since telling two live endpoints apart is all that uniqueness was for, but the URL never comes back.
The tempting alternative is to answer 404 for both, so nobody learns which slugs once existed. Guessing a slug is 160 bits of work, so anybody holding one was given it, by a log line or an old integration or a screenshot, and already knows it existed. The distinction tells him nothing.
It tells the honest half of the world quite a lot. A source still posting to a rotated URL sees from a 410 that it needs a new one; from a 404 it concludes that somebody typed the path wrong, and a person then goes looking for a bug in the path instead of in the rotation.
The first trigger without a server
Every earlier trigger ran against one existing machine: provisioned, reinstalled, deleted. A webhook run has no machine, no plan, no address, no integration, no agent, and nobody logged in.
That sounds small and it was the sharpest edge in the round. The variable catalogue marks each group with the triggers it is available for, and the constant meaning "always" was literally the list of all triggers. A fifth value would therefore have offered a webhook flow every server, plan, address, network and agent variable in the picker; the validator would have approved them, and the first real request would have failed at render time, inside a run nobody is watching.
So there is now a separate list answering a different question: does this trigger run against a server. A sixth trigger has to answer it first. What is missing from a webhook run is pruned out of the context rather than left present and empty, and an action node in such a graph is refused at save time.
The body lands one level deep, as string, number or boolean. Unknown fields are skipped rather than refused, which is the one place a webhook payload differs from one the customer wrote himself: a hand written payload with a typo should fail, but a third party's message contains nulls, nested objects and arrays because it was never written for us, and refusing the whole request over one extra field makes every real source unusable.
What we would write down
- If the URL is the credential, make it one. Random, not derived, unguessable, revocable, never reissued.
- A secret you compute with cannot be hashed. Encrypt it, bind it to its owner, fail closed when it will not open.
- Sign the clock, not just the body. A timestamp outside the signature is an anti replay measure that does not replay anything.
- Enforce a size cap while the bytes arrive. A check after reading already paid the cost.
- Rate limit what the customer configured, not what an attacker controls.
- Log the refusals, not the payload. Refusals make "nothing happens" answerable; the payload is somebody else's data.
- When a new case has no subject, find every list that meant "all cases". One of them is a synonym for the enum, and it will not say so.
For clarity: the endpoints, the schemes, the receipts and the webhook trigger are built and running. Unproven is the rate limiter's in process fallback under a real cache outage, behaviour after a rotated platform secret, and any of it on a customer's own domain.
From the platform work on Provibr. How the graph behind the trigger is kept finite is in a flow is a graph, and a graph has to be finite, and how the canvas got its wire is in a wire you can drag, and exactly one trigger.