The Cheapest Control Is the One That Deletes the Exposure
A chat integration appeared to require a public webhook endpoint into a machine I own, and the transport that removed the endpoint entirely cost less than any control I would have put in front of it.
- Author
- Aaron Smith
- Reading time
- 9 min
The cheapest control for an exposure is usually the one that removes it, and teams reach for it last because removal does not look like work. Last week I installed a Slack adapter into a self-hosted agent host, an open-source project I run rather than one I wrote, on a machine I own. The bot could post into Slack and nothing anybody typed back ever reached the host, because the adapter that ships with the project is webhook-only: Slack delivers Events API traffic by POSTing it to a public URL, and a local install does not have one.
The obvious next step was to give it one.
I did not. The version that shipped that evening removed the inbound path rather than defending it, and the interesting part of the decision was not the transport. It was refusing to let the second path have its own security posture.
What the webhook was actually asking me to accept
To receive one message from Slack, the host has to be listening. The project's shared webhook server in src/webhook-server.ts starts lazily on the first adapter registration, binds 0.0.0.0 on port 3000 unless WEBHOOK_PORT says otherwise, and routes by path to /webhook/{adapterName}. Reaching that from Slack needs a public name, a route to the machine, and TLS termination in front of it.
The authentication is real and it is narrow. The adapter's verifySignature rebuilds v0:{timestamp}:{body}, computes an HMAC-SHA256 with the signing secret, compares it to the x-slack-signature header with a timing-safe comparison, and rejects any request whose x-slack-request-timestamp is more than 300 seconds from the server's clock. Slack's Events API documentation adds the constraint on the other side: the app should respond with an HTTP 2xx within three seconds, and a failed delivery is retried up to three times, the first almost immediately, the second after 1 minute, the third after 5 minutes.
Read what that signature does and does not do. It answers "did Slack send this body" for a request that has already arrived. It has no opinion about who may open a TCP connection to port 3000, so the accept, the TLS handshake, the HTTP parse and the read of the full body all happen for anyone who finds the port, and the secret is consulted afterward. Keeping that shape meant owning a tunnel or a public address, a certificate and its renewal, a firewall rule, rate limiting, and something that notices when the endpoint starts taking traffic that is not Slack. Five controls, to protect one integration that carries chat messages to one person.
Deleting the ingress instead of guarding it
Slack publishes a second transport, and its documentation states the property directly: Socket Mode "allows your app to use the Events API and interactive features—without exposing a public HTTP Request URL." The host holds an app-level token, calls apps.connections.open, receives a wss:// URL, and dials out. Events arrive down a connection the host initiated.
The change in src/channels/chat-sdk-bridge.ts is one branch added ahead of an existing chain, which reduces to this:
// before: Slack has no Gateway listener, so it fell to the else
if (gatewayAdapter.startGatewayListener) { /* Discord */ }
else { registerWebhookAdapter(chat, adapter.name); }
// after
if (config.startSocketListener) {
socketAbort = new AbortController();
await config.startSocketListener(chat, socketAbort.signal);
} else if (gatewayAdapter.startGatewayListener) { /* Discord */ }
else { registerWebhookAdapter(chat, adapter.name); }
What the after prevents is not an attack. It prevents the existence of the thing an attack would need. On a host running only the Slack channel, registerWebhookAdapter is never called, so the shared server never starts and nothing binds port 3000. Selection is by environment: when SLACK_APP_TOKEN is present the socket listener is installed, and when it is absent the adapter falls back to the webhook it always used. The whole change is 109 added lines in src/channels/slack.ts, which grew from 30 lines to 135, plus 18 in the bridge and one dependency, @slack/socket-mode@2.0.7.
The signature that stopped meaning anything
The reason this was worth doing in an evening is that Socket Mode delivers each events_api envelope with a payload that is byte-for-byte the body Slack would have POSTed. So the socket listener re-signs that payload with the same signing secret, wraps it in a Request carrying the two Slack headers, and hands it to chat.webhooks.slack(...), which is the same function the HTTP server would have called. The adapter's parsing, the bridge's onDirectMessage and onNewMention dispatch, the thread handling: all of it is reached by one path from two transports, with no second implementation to drift. That is a maintainability win, and I want to be precise that it is not a security win.
Decorative verification. A check that runs on every request, passes on every request, proves nothing, and is kept because removing it would make the diagram look worse. On the socket path the HMAC is computed by the host with a secret only the host holds and verified by the host microseconds later. The listener also stamps a fresh Math.floor(Date.now() / 1000) as the timestamp, so the adapter's 300-second replay window compares the host's clock against itself and can never fail. What actually authenticates inbound events here is the app-level token presented to apps.connections.open and the fact that the bytes arrived on a TLS socket the host dialed to Slack. Write that down on the design, or the next reader sees "signature verified" on both branches and never asks which one is load-bearing.
Why guarding wins by default
A guard is a deliverable. It has a name, an owner, a box on the diagram, a dashboard, and a sentence you can put in an audit response. The artifact of a good deletion is an absence, and nobody presents an absence at a quarterly review, so the option producing less evidence of effort loses to the option producing more even when it costs more to run.
The second reason is timing. Once a webhook is live and a counterparty is sending to it, removal means a coordinated migration with somebody else's release calendar. Here the window in which the decision cost 109 lines was open for a few hours, between installing the adapter and configuring a public route to it.
Whoever writes the integration ticket should put one line in it before any code is written: which inbound transports does this vendor support, and what does each one require to exist on your side.
The strongest objection
Slack steers production apps the other way and says so plainly. Its comparison of the two delivery modes states that "once deployed and published for use in a team setting, we recommend using HTTP request URLs", that "if you intend to submit your app to be available for use in the Slack Marketplace, using HTTP is a requirement", and that Socket Mode allows up to 10 open WebSocket connections at a time. I picked the transport the vendor documents as the one you grow out of.
That is right for the general case and I concede it. An HTTP request URL is stateless and scales horizontally behind whatever already terminates TLS for you. A socket is a stateful connection somebody has to keep alive, and the reconnect behavior in my version is the library's, not mine, with a log.warn on disconnect and nothing counting them. If this host served a workspace of thousands under an availability commitment, the webhook plus its five controls would be the correct architecture and this post would be wrong.
The concession has a second half that costs me something. The socket listener drops every envelope whose type is not events_api, so slash commands and interactive payloads such as button clicks never arrive. Deleting the ingress deleted a capability along with it, and the honest description of the result is a smaller integration rather than only a safer one.
When deletion is actually on the menu
The useful question at design time is not whether removal beats guarding, which it does when it is possible, but whether it is possible at all. That is a short lookup:
| Exposure | Deletion available | What replaces it | What you give up |
|---|---|---|---|
| Vendor pushes events to you | Usually | Outbound socket, or you poll | Callbacks the alternate transport does not carry |
| Operators need a shell on a host | Yes | Agent dials a managed session broker | The agent becomes the trust anchor |
| Partner drops files to you | Usually | You fetch on a schedule | Latency, and a schedule somebody owns |
| Service needs a database port | Sometimes | Private endpoint, no public address | A separate break-glass path for migrations |
| Public browsers call your API | No | Nothing. Guard it properly. | Nothing to give up |
Row one is where teams stop looking, because the vendor's quickstart shows the webhook first and the webhook works. Row two has been solved in public for years: AWS documents Session Manager as providing node management "without the need to open inbound ports, maintain bastion hosts, or manage SSH keys", which is the same inversion, an agent holding a connection outward instead of sshd holding a port open inward.
None of this is a new control. NIST SP 800-53 Rev. 5 has carried CM-7, Least Functionality, for years: configure the system to provide only organization-defined mission-essential capabilities, and prohibit or restrict the functions, ports, protocols, software and services you name. Its enhancement CM-7(1) asks for periodic review to find unnecessary ports and services and disable them. But a reviewer walking this host in July would have found port 3000 open, traced it to a working Slack integration, and marked it necessary, because by then it would have been. Least functionality is enforceable as a hardening pass and decisive only as a design question, and the two happen months apart.
What would change my mind
Two things would reverse this decision. The first is any requirement for a slash command or a button in a message, because the socket path as written cannot carry an interactive envelope, and the day I need one is the day I either write that branch or go back to a public URL.
The second is worse and I should say it. There is no test covering the socket path in src/channels/chat-sdk-bridge.test.ts and no metric on reconnects beyond a log line, so if the connection drops in a way the library does not recover from, I find out when somebody tells me they messaged the bot and nothing happened. That is exactly the failure that started this, unchanged, moved onto a different transport.
Deletion is also not always on offer, and I got the reminder just under four hours earlier the same day. The host was crash-looping on startup because the machine's system Node is v26, ABI 147, and better-sqlite3@11.10.0 is built against ABI 127. There is no clever way to not have that dependency, so I pinned the runtime to node@22.22.2 in .npmrc and took on a constraint I now maintain. Two commits, one removing an exposure and one accepting an obligation, and the difference between them was never how hard the work was but whether anything had to exist afterward.
The rule I carry out of it: when an integration offers you more than one transport, price them by what will be listening when the work is done, and if the answer is nothing, stop shopping for controls.