The webhook that received plumbers' SMS replies on a lead network accepted any POST with no authentication check. That meant anyone who knew a plumber's mobile number and the endpoint URL could send a fabricated YES, claim a lead on their behalf, and on a brand that takes card payments, trigger an off-session charge against a stored card. The hole was found on 2026-10-15 and closed with a two-step Basic auth rollout proven by commit on 2026-10-18. There is no evidence it was exploited.
Why the Risk Was Real, Not Theoretical
A spoofed YES is not just a nuisance. In a lead network the reply drives routing logic: the first plumber to reply YES gets the job. If that reply can be faked, a competitor or a curious person with a browser and curl can:
- Claim leads without doing any work, starving legitimate plumbers of revenue.
- Trigger a card charge on any brand that runs off-session payments on lead acceptance.
- Flood the router with fake replies and make the whole network unreliable.
The webhook was not obscure. The SmsUrl is configured in the telephony provider's dashboard, and the endpoint shape is predictable. Obscurity was the only defence, and that is not a defence.
How Twilio's Credential Embedding Works
Twilio supports credentials embedded directly in the SmsUrl:
- Format:
https://username:password@your-host/webhook/path - Behaviour: Twilio reads those credentials and sends them as a standard HTTP Basic auth header on every delivery.
- n8n side: n8n's webhook node has a built-in Basic auth option. Enable it, set matching credentials, and n8n handles the challenge automatically.
No custom middleware. No signature verification library. The protocol does the work.
The phone/webhook_auth.py docstring captures the three modes the rollout uses:
- absent: no credentials in the SmsUrl, webhook open.
- prepare: credentials in the SmsUrl, webhook still open. Twilio is now sending the header but the webhook does not yet demand it.
- on: credentials in the SmsUrl, webhook demands them. Any request without valid credentials gets a 401.
The Rollout Order Matters
Flipping straight from absent to on would drop real SMS traffic for the window between the webhook demanding credentials and Twilio actually sending them. The correct order:
- Set prepare on the line. Update the SmsUrl to include credentials. Sync. Confirm Twilio is delivering the header by checking n8n execution logs.
- Set on. Enable Basic auth on the n8n webhook. Sync.
- Prove it. Send an uncredentialed POST, confirm 401. Deliver a real SMS through Twilio, confirm the router runs.
Each line in the network was rolled out individually using this sequence. Two more lines were switched on in commit 2b30c43 on 2026-10-18, each proven by the same 401 plus router-run check.
Proving the Fix Without Breaking Real Traffic
The test that matters most is not the unit test. It is the live proof:
- Uncredentialed POST returns 401. This confirms the webhook is actually enforcing auth, not just advertising it.
- Real Twilio-delivered SMS still runs the router. This confirms the credentials in the SmsUrl are correct and Twilio is sending them properly.
Both checks were run after each line was switched to on. The commit message for eaf1f02 records that the spoof POST returned 401 and the Twilio-delivered SMS still ran. That is the acceptance criterion.
The test suite in tests/test_webhook_auth.py covers 13 cases. The presence of test_a_mismatch_never_prints_the_password is worth noting separately.
The Second Problem: the Check That Printed the Password
After the auth was live, a re-read of the code found that the mismatch branch logged the received password. The intent was probably debugging. The effect was that any failed auth attempt would write the expected or received credential to the log.
That is a second vulnerability layered on top of the first:
- Logs are often less protected than secrets stores. A credential in a log can end up in a log aggregator, a support export, a diff, or a screenshot.
- The credential was rotated immediately. The SmsUrl was updated with a new password and the n8n credential was replaced. Commit
eaf1f02records both the rotation and the replacement. - Every comparison now masks the value. The test
test_a_mismatch_never_prints_the_passwordasserts this at the unit level so a future refactor cannot silently reintroduce the leak.
The passwords were masked in every diff row in the commit history. That is the right call even in a private repo.
What the Commit History Shows
Three commits tell the story cleanly:
11546e7(2026-10-15): Finding documented, Basic auth approach chosen, passwords masked in every diff row.eaf1f02(2026-10-18): First line proven live. Spoof POST returns 401. Real SMS runs. Password-printing bug found, credential rotated, n8n credential replaced.2b30c43(2026-10-18): Two more lines switched on, each proven by the same 401 plus router-run check.
Three days from finding to all lines protected. The gap between finding and fix was not urgency, it was the prepare-then-on rollout needing real SMS traffic to confirm before flipping each line.
Patterns Worth Carrying Forward
A few things generalise beyond this specific setup:
- Any webhook that drives money movement needs caller authentication. A 200 OK to an unauthenticated POST is an open door if the payload shape is guessable.
- Twilio's credential embedding is underused. Most guides jump straight to signature verification, which is more robust but also more code. For an n8n workflow, Basic auth via the SmsUrl is fast to ship and fast to prove.
- Prepare-then-on is the safe rollout pattern for any auth you are adding to a live endpoint. The window where you are sending credentials but not yet demanding them is the safety net.
- Re-read your own fix before closing the ticket. The password-printing bug was in the code before the fix. It would have survived indefinitely if the re-read had not caught it.
The stripe-stale-payment-failed-replay note covers a related pattern where stale state in a payment flow causes unintended charges. Worth reading alongside this one if you are thinking about off-session charge triggers.
What I'd do differently
- Add the prepare-then-on rollout to a checklist, not just the docstring. The docstring in
webhook_auth.pycaptures the modes clearly, but a checklist item in the deploy process would make it harder to skip prepare under time pressure. - Run a log scan for credential strings before declaring a fix done. The password-printing bug was caught by re-reading code. A grep across recent log output for the credential value would have caught it faster and more reliably.
- Write
test_a_mismatch_never_prints_the_passwordbefore shipping the fix, not after finding the bug. The test is now there, but it was written reactively. Adding it to the auth implementation template means the next webhook gets it for free. - Document the credential rotation in the same commit as the fix. The rotation landed in
eaf1f02alongside the live proof, which is good. Making that explicit in the commit message template would make the audit trail cleaner for anyone reading the history later.




