# A Fake SMS YES Could Have Won a Lead and Charged a Card

> An open webhook on a plumber lead network accepted any POST, meaning a spoofed YES could claim a lead and trigger a card charge. Basic auth fixed it with a zero-downtime rollout.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-10-25
- Updated: 2026-10-25
- Tags: webhooks, security, twilio, n8n
- Canonical: https://dil.dev/notes/twilio-reply-webhook-basic-auth/

## Key takeaways

- An unauthenticated webhook that triggers business logic is a spoofing surface, not just a theoretical risk.
- Twilio supports credentials embedded in the SmsUrl, so it answers an n8n Basic auth challenge without any middleware.
- Roll out in two synced steps: prepare first so Twilio carries the credentials, then on so the webhook demands them.
- A re-read check that prints a password on mismatch is itself a vulnerability. Mask every comparison.
- Rotate the credential immediately if it appears in any log or diff, then replace the stored secret.

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:

1. **Set prepare on the line.** Update the SmsUrl to include credentials. Sync. Confirm Twilio is delivering the header by checking n8n execution logs.
2. **Set on.** Enable Basic auth on the n8n webhook. Sync.
3. **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 `eaf1f02` records both the rotation and the replacement.
- **Every comparison now masks the value.** The test `test_a_mismatch_never_prints_the_password` asserts 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](/notes/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.py` captures 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_password` before 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 `eaf1f02` alongside 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.

## Quick answers

**How could someone send a fake SMS reply to a webhook?**

If the webhook accepts any POST without checking the caller's identity, an attacker only needs to know the endpoint URL and the expected payload shape. They send a POST that looks like a plumber replying YES and the system treats it as genuine.

**How does Twilio authenticate itself to an n8n webhook?**

Twilio supports credentials embedded directly in the SmsUrl, for example https://user:pass@your-host/webhook. When Twilio delivers an SMS it sends those credentials as a standard HTTP Basic auth header, which n8n's built-in Basic auth challenge accepts without any custom middleware.

**What does the prepare mode do and why is it needed?**

Prepare mode tells Twilio to start sending credentials in every request but leaves the webhook still accepting unauthenticated POSTs. This gives you a window to confirm Twilio is actually sending the header before you flip the webhook to demand it, so real SMS traffic never drops.

**What is the on mode and how do you prove it is working?**

On mode makes the webhook reject any request that lacks valid credentials with a 401. You prove it by sending an uncredentialed POST and confirming the 401, then delivering a real SMS through Twilio and confirming the router still runs.

**Why was the credential rotated after the fix was deployed?**

A re-read of the code found that the mismatch check printed the password to the log. Even though the webhook was now protected, the credential had potentially been exposed in log output, so it was rotated and the stored n8n credential was replaced.

**How do you prevent a comparison from leaking a password in logs?**

Every comparison that involves a secret should mask the value before any logging or error output. The test suite now includes a dedicated test that asserts a mismatch never prints the password, so a future refactor cannot accidentally reintroduce the leak.
