A letterbox in the DMZ
How to accept webhooks from the public internet without exposing anything that matters — by building the smallest possible thing and putting it where it can do no harm.
Sooner or later a useful service wants to push data to you rather than wait to be asked. A courier's tracking system, a payment provider, a monitoring tool — they all say the same thing: "give us a URL and we'll POST to it when something happens." That URL has to live on the public internet, reachable by a company you don't control, at any hour.
If your instinct is to recoil at that, good. That instinct is correct. But the answer isn't to avoid webhooks — it's to build the receiving end so that being reachable costs you nothing.
The principle: build a letterbox, not a house
A letterbox has exactly one job. Post goes in; nothing else happens. You can't climb through it, it holds no valuables, and if someone jams it the worst case is that a letter waits outside.
Most webhook receivers are the opposite of this. They're a full web application — a framework, a database, an admin login, a dozen dependencies — and one route of that sprawling thing happens to accept the webhook. Every other part is attack surface you took on for no reason.
So the design goal is a receiver that:
- accepts
POSTon a single path, and answers 404 to everything else; - checks a shared secret before it believes a word;
- writes the payload to disk exactly as received, interpreting nothing;
- holds no credentials to anything else you own.
That last point is the quiet one. If the receiver is ever compromised, what does the attacker get? With a letterbox: some inbound messages and a token that only lets them send more inbound messages. Nothing that reaches the rest of your world.
Keep it small enough to audit
Here is the entire idea, in about as much code as it takes to describe it:
# One path. One method. One secret. Store verbatim.
def handle(request):
if request.path != "/hook" or request.method != "POST":
return 404
if not constant_time_equal(request.header("Authorization"), EXPECTED):
return 401
if request.length > MAX_BODY:
return 413
store(request.body) # raw bytes, no parsing
return 200
No template engine. No ORM. No router that can be tricked into serving a file. When the whole program fits on a screen, you can actually read it and know what it does. That readability is the security control.
Interpreting nothing matters too. If you parse the payload on the exposed box, a malformed message becomes a bug becomes a foothold. Store the raw bytes and let something inside your network make sense of them later, on its own terms.
Put it where it can do no harm
A minimal receiver is still a computer on the internet. The second layer of defence is where it sits.
This is what a DMZ — a "demilitarised zone" — is for. It's a third network segment, belonging to neither the outside world nor your trusted network, with one rule that does all the work:
The DMZ may never start a connection into your trusted network.
Traffic flows one way. Your internal systems reach into the DMZ to collect what arrived; the DMZ box can shout at the internet all it likes but cannot touch your file server, your workstation, or your other machines. Firewalls care about who starts a conversation, not which way the data ends up flowing — so a fully compromised letterbox still finds itself in an empty room, talking to a wall.
A useful test while building it: from the DMZ box, try to reach the things it must never reach.
# these should all TIME OUT from inside the DMZ
nc -z -w4 <your-router> 22 # no
nc -z -w4 <your-workstation> 22 # no
nc -z -w4 <internal-host> 443 # no
# these should connect
nc -z -w4 1.1.1.1 443 # yes — the open internet
Don't assume the rules work. Make each connection and watch it fail. A rule that reads correctly in the firewall UI and a rule that actually blocks a packet are two different claims, and only the second one matters.
The shape that falls out
Put those two ideas together and you get a pleasingly boring architecture:
| Layer | Job |
|---|---|
| Firewall | Forward one port to the DMZ; block the DMZ from reaching inward |
| DMZ box | Run the letterbox and nothing else |
| Letterbox | Accept POST, check the secret, store raw, answer 404 to all else |
| Trusted network | Reach out to collect new messages on its own schedule |
Within hours of opening the port, the scanners find you — that's simply the weather of a public address. They ask for /robots.txt, /sitemap.xml, the front page, a login. They get 404, 404, 404. There's no version banner to fingerprint, no framework to match against a list of exploits, no login to guess. You win that exchange by being uninteresting.
Which is the whole point. The most secure service isn't the one with the cleverest defences. It's the one that gives an attacker nothing to work with in the first place.