Webhook Security for Automations: Signatures, Secrets, and Least Privilege
Verify Stripe and GitHub signatures on the raw body, reject replayed events, and allowlist vendor IPs before n8n runs CRM writes or payment side effects.
William Spurlock Founder — Spurlock Studios Updated 22 MIN
An open webhook URL is not an integration. It is a public function that mutates your business systems if you let it.
Production automations verify who is calling, reject traffic that is too old or already applied, and assume someone will replay a captured POST. This is the security baseline Spurlock Studios applies to n8n webhooks after 500+ automations — before any CRM or payment node runs.
Parent: Production n8n handbook.
The short answer
- Verify the vendor signature on the raw body — Stripe’s
Stripe-Signatureheader and GitHub’sX-Hub-Signature-256are HMAC checks, not “trust the JSON.” - Reject replays — Stripe signs a timestamp and defaults to a 5-minute tolerance. GitHub does not; store
X-GitHub-Deliveryand apply idempotency keys. - Allowlist webhook IPs — Stripe publishes a dedicated webhook list. GitHub publishes
hooksranges onGET /meta. Neither list is identity. - Do not confuse n8n Header auth with HMAC — a shared header secret is weaker than a body signature. Use both only when the vendor cannot sign.
- Least privilege on the side-effect token — a valid Stripe event still should not let the workflow delete the workspace.
What threats should a production webhook actually cover?
You do not need nation-state paranoia. You need the five failures that show up in real n8n graphs.
| Threat | What the attacker does | What breaks |
|---|---|---|
| Forged event | POSTs JSON to your production URL | Fake CRM rows, fake “paid” emails, fake deploys |
| Replay | Resends a captured, still-valid signed body | Duplicate invoices, double fulfillment, double Slack pings |
| Secret leak | Copies a URL, whsec_, or GitHub secret from git or a screenshot | Anyone who holds the secret can mint valid signatures |
| Over-scoped token | Uses founder OAuth that can delete the workspace | One bad event becomes a wipe |
| Confused deputy | Trusts a field (email, price_id, repo) as identity | Client-side form values become server truth |
Unsigned traffic is the cheap version of the first row. Signed-but-replayed traffic is the expensive version of the second.
- Forged POST without a signature is rejected
- Bad signature is rejected
- Old timestamp (Stripe) is rejected
- Same event ID twice applies once
- Side-effect credential cannot do more than the workflow needs
Skip any three of those and you are running an honor system.
How do Stripe and GitHub sign webhook deliveries?
Both vendors sign the bytes they sent, not the object your stack built after JSON.parse. That distinction is the whole job.
| Vendor | Header | Scheme | Replay hint in the signature |
|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 over timestamp.raw_body; live scheme is v1 | Timestamp t= is signed. Libraries default to 5 minutes. Tolerance 0 disables the recency check. |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 hex digest, prefixed sha256= | No timestamp. Replay defense is X-GitHub-Delivery plus your store. |
Stripe’s header looks like t=1492774577,v1=…. The timestamp is part of the signed payload, so an attacker cannot slide t= forward without invalidating v1. Stripe documents this under preventing replay attacks: if the signature is valid but the timestamp is too old, reject it. Keep the n8n host on NTP. Stripe also warns that a tolerance of 0 turns the recency check off.
GitHub computes an HMAC hex digest of the raw body with your secret and sends it as X-Hub-Signature-256: sha256=…. Their docs are explicit: never compare with ==. Use a constant-time check such as Node’s crypto.timingSafeEqual. The older X-Hub-Signature header is HMAC-SHA1 and exists for legacy only — do not verify against it.
Stripe’s own signature troubleshooting names the usual break: the framework parsed JSON, re-ordered keys, or changed whitespace before verification. GitHub says the same if a proxy rewrites the body.
Provider-specific facts worth budgeting for:
- Stripe test vs live secrets differ even if the URL is the same. A Dashboard
whsec_will not verify events fromstripe listen, and the reverse is also true. - Stripe retries mint a new signature and timestamp for each delivery attempt. A 5-minute window still accepts a legitimate retry. It does not accept a three-day-old capture.
- GitHub redelivery keeps the same
X-GitHub-Delivery. That is the idempotency key, not a new event. - GitHub’s documented test vector — secret
It's a Secret to Everybody, payloadHello, World!— must producesha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. If your Code node cannot hit that, stop and fix encoding before you touch production.
How do you verify signatures in n8n without breaking the raw body?
n8n’s Webhook node can require Basic, Header, or JWT auth. That is useful. It is not Stripe or GitHub HMAC.
Header auth checks that a caller sent a static name/value pair. HMAC checks that the body matches a signature the vendor computed with a shared secret. A stolen URL plus a guessed header is still a forged event if you never check the body.
Pattern that survives production:
- Webhook node: HTTP Method
POST, enable Raw Body, set Respond to Immediately (or a Respond to Webhook node that returns2xxfast). - Optional: IP(s) Allowlist for the vendor webhook ranges.
- Code node: read the raw bytes, compute the expected signature, compare constant-time, then reject.
- Only after a pass: parse JSON, validate schema, apply idempotency, then side effects.
Stripe wants a 2xx before heavy work — their webhook handler guide says return success prior to logic that can time out. GitHub is sharper: respond within 10 seconds or they tear down the connection and mark the delivery failed.
Pseudo-check for a GitHub-style header. Adapt the signed string for Stripe (${t}.${raw}):
const crypto = require("crypto");
const secret = $env.WEBHOOK_SECRET;
const header = $headers["x-hub-signature-256"];
const raw = $json.rawBody; // only if Raw Body is on
if (!header || !raw) {
throw new Error("Missing signature or raw body");
}
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid webhook signature");
}
return [{ json: JSON.parse(raw) }];
A !== on hex strings is how toy demos get written. GitHub’s docs call that out as a timing-attack footgun. Match their bar.
| n8n setting | What it actually does | What it does not do |
|---|---|---|
| Authentication → Header auth | Rejects callers missing a static header | Does not prove the body is untouched |
| Authentication → JWT | Checks a bearer token you issued | Does not verify Stripe or GitHub HMAC |
| IP(s) Allowlist | 403s callers outside the list | Does not survive NAT, shared egress, or a stale list |
| Raw Body | Keeps the bytes the vendor signed | Does nothing unless the next node uses them |
| Only Run If | Expression gate after IP + auth | Fail-open if the expression errors — n8n logs a warning and lets the request through |
| Respond → Immediately | Returns Workflow got started fast | Does not verify signatures by itself |
If the vendor offers signing, use it. A shared query-param secret is weaker and still better than nothing — rotate it, never log it, never paste it into a ticket.
How do you stop replay attacks after the signature already passed?
A replay is a valid payload, resent later. The signature still matches. That is the point.
Stripe’s mitigation is the signed timestamp. Official libraries default to 5 minutes between t= and now. Widen that only if your host clock is honest and you have a reason. Do not set tolerance to 0. Stripe says that disables the recency check entirely.
GitHub’s mitigation is not in the HMAC. Their best-practices page tells you to treat X-GitHub-Delivery as unique per event, and notes that a redelivery reuses the same delivery id. If you key only on “we have never seen this body,” a redelivery looks new. If you key on the delivery id, a redelivery is a no-op — which is what you want after you already applied the side effect.
| Layer | Stripe | GitHub |
|---|---|---|
| Origin | Stripe-Signature v1 | X-Hub-Signature-256 |
| Freshness | Signed t=, default 5 minutes | None in the signature |
| Duplicate apply | Event id (evt_…) in your store | X-GitHub-Delivery in your store |
| Legitimate retry | New signature + new timestamp; same event id | Same delivery id on redelivery |
| Business key | data.object.id + event type | Delivery id, then repo + action if you must |
Combine all three:
- Signature valid.
- Timestamp inside policy (Stripe) or event
creatednot older than your SLA (everyone else). - Idempotency key written before the CRM or payment node.
Stripe live-mode delivery retries for up to three days with exponential backoff. Sandbox retries three times over a few hours. Dashboard resend works for 15 days; the CLI resend window is 30 days. Those numbers are from Stripe’s webhook docs. Your idempotency store has to outlive the retry window or a late retry becomes a second apply.
- Timestamp / recency check on
- Event or delivery id stored before side effects
- Store TTL ≥ vendor retry window
- Redelivery of the same id is a 200 with no second write
- Clock synced (NTP) on the n8n host
Signatures prove origin. They do not, by themselves, stop replays inside the validity window.
When do IP allowlists help, and when do they lie?
Allowlists cut anonymous internet noise. They do not replace HMAC.
Stripe tells you to use both IP allowlisting and signature verification. The trap is allowlisting the wrong list. Stripe’s IP page separates api.stripe.com addresses from webhook notification addresses. Webhooks do not come from the API list. Subscribe to Stripe’s API announce list if you pin IPs — they give seven days’ notice before changes.
GitHub tells you the same split. Allow GitHub’s IP addresses, but pull current ranges from GET /meta and use the hooks element, not every GitHub CIDR. They change. About GitHub’s IP addresses is the overview; delivering webhooks to private systems is the reverse-proxy version: HTTPS POST only, from hooks ranges, then still verify the signature on the inner app.
n8n’s Webhook node has IP(s) Allowlist. A caller outside the list gets 403. Blank means everyone. That is the right place for Stripe’s webhook IPs or GitHub’s hooks ranges on self-hosted n8n sitting behind your proxy.
| Allowlist | Use it for | Do not use it for |
|---|---|---|
| Stripe webhook IPs | Inbound POSTs to the payment workflow | Calls out to api.stripe.com |
GitHub hooks ranges | Inbound POSTs to the deploy / issue workflow | api.github.com client traffic |
| Your office / VPN egress | Private enterprise callbacks | SaaS vendors that will not publish stable egress |
| n8n Header auth + allowlist | Unsigned legacy vendors you are retiring | Stripe or GitHub (they already sign) |
Allowlists lie when:
- The vendor shares egress with other customers and you treat the IP as a person.
- You freeze a copied list and miss a rotation.
- A proxy or Cloudflare hop makes n8n see the proxy IP, not Stripe.
- You skip HMAC because “the firewall is enough.”
On n8n Cloud you often cannot put a vendor allowlist in front of the public webhook the way you can on a box you own. You still verify signatures. The automation lane is where we decide hosting vs application controls; this post is the application control.
If a vendor truly cannot sign, put a reverse proxy with IP allowlist, a shared header secret, or mTLS in front. Track those endpoints as exceptions with an expiry to renegotiate. Do not let “the old CRM cannot HMAC” become the default for Stripe.
Is a hidden webhook URL enough protection?
No. URLs leak.
They leak in browser history, Slack huddle screenshots, support tickets, HAR files, n8n execution previews, and the one time someone pasted a production path into a public Notion page. GitHub’s own warning is blunt: do not put API keys or secrets in the payload URL. Use a webhook secret instead.
Obscurity is a bonus layer. It is never the only layer.
| Layer | Stops | Fails when |
|---|---|---|
| Unguessable path | Casual scanners | The URL is copied once |
| TLS | Passive tap on the wire | You accept HTTP in “just staging” |
| Header / Basic auth | Callers who only have the URL | The static secret is in the same ticket as the URL |
| HMAC on raw body | Forged or mutated JSON | You verified a re-serialized object |
| Replay window + idempotency | Captured valid POSTs | You stored the key after the write |
| Least-privilege token | Blast radius after a valid event | Founder OAuth is the production principal |
Treat production webhook URLs as secrets anyway. Separate test and production paths. Disable unused test webhooks. Do not paste full URLs into public tickets.
n8n gives you a random path by default for a reason. Do not “clean it up” to /webhook/stripe on a host that is already in Shodan.
How should n8n store secrets and rotate them?
Store signing secrets and API tokens in n8n credentials or environment variables — not in Code node string literals, not in workflow sticky notes, not in the Webhook path.
Stripe generates a unique secret per endpoint. Test and live secrets differ. If you roll a Stripe secret, Workbench lets you expire the old one immediately or delay expiration for up to 24 hours. During that window Stripe signs with multiple secrets. Accept secret_current and secret_previous; reject only when neither matches. That procedure is in Stripe’s webhook docs under rolling endpoint signing secrets.
GitHub does not give you that dual-sign window in the product UI. If you rotate the GitHub secret, old in-flight deliveries fail verification. Keep the previous secret in the Code node for a short, named window if you must, then delete it.
| Rule | Why |
|---|---|
| One secret per environment | A leaked staging whsec_ must not verify live events |
| Rotate on a calendar and on staff changes | 90 days is a common default; incident is immediate |
| Restrict workflow export | JSON exports are how secrets leave the building in comments |
| Redact error notifications | Slack “workflow failed” must not echo the header or the secret |
| Named rotation owner | “We should rotate” without a name is how secrets turn five |
If a secret may have leaked, rotate first, investigate second.
GitHub’s validation docs also say: never hardcode the token, never push it to a repository. That includes the n8n workflow JSON you commit “for backup.”
What does least privilege look like on the side-effect credentials?
Webhook auth answers “did Stripe or GitHub send this?” It does not answer “should this workflow be allowed to do that?”
Each connected app should use a principal that can only do what the graph needs.
| Workflow need | Bad scope | Better scope |
|---|---|---|
| Create CRM contacts | Full admin | Contacts write + limited read |
| Send newsletter drafts | Full account owner | Draft create only |
| Read one spreadsheet | Edit all Drive | Single file access |
| Slack notify | Workspace admin | Bot to one channel |
| Mark a GitHub issue | Org owner | Contents or issues on one repo |
| Record a Stripe payment in the ERP | Stripe secret key with deletes | Restricted key: read Events, write the one object you own |
Personal founder OAuth for company production systems is a recurring audit finding. Use a service account with a named human owner.
GitHub’s best practices start with a smaller idea that maps here: subscribe to the minimum number of events. A workflow that listens to * will one day receive an action you never designed for. Check X-GitHub-Event and the payload action before you write.
- Production credential is not a founder’s personal login
- Scope list written next to the workflow, not “full access so it works”
- Event allowlist (Stripe types / GitHub events) is explicit
- Irreversible classes (refunds, deletes, deploys to prod) need a human gate
A valid signature on an event you did not subscribe to is still a no-op.
Why does app-level authorization still matter after a valid Stripe signature?
Even with a valid Stripe signature, your code should not trust a price_id that originated in a client-side form. Webhooks authenticate the provider. Your workflow still enforces business rules.
Classic failure: Checkout fires checkout.session.completed. The workflow reads metadata.sku from the session and fulfills whatever string the browser stuffed in. The signature is valid. The sku is not a price you sell.
| Trust this | Do not trust this |
|---|---|
| Event id + type after HMAC pass | Price, SKU, or role fields the browser set |
| Stripe object retrieved by id with your secret key | Client-supplied amounts |
GitHub X-GitHub-Delivery + event + action | A ref you did not allowlist for deploy |
| Your idempotency store | “The payload says this is the first time” |
Procedure after auth:
- Verify signature + recency.
- Confirm event type is on your allowlist.
- Load the canonical object from the vendor API when money or access changes.
- Compare the result to your catalog, not to the webhook body alone.
- Then write.
Stripe’s quickstart frames the same idea as “secure your webhook” so a server acting like Stripe cannot inject events. The follow-on is yours: a server that is Stripe can still describe a purchase you do not offer if you skip the lookup.
What n8n Webhook node settings belong in production?
Settings that belong on every money or CRM webhook:
| Setting | Production value | Why |
|---|---|---|
| Webhook URL | Production URL, workflow published | Test URLs die when you leave the editor |
| HTTP Method | POST only | GET webhooks become cache and crawler bait |
| Authentication | Header/JWT if the vendor can send it; HMAC in Code regardless | Defense in depth, not a substitute |
| Raw Body | On | Signature bytes |
| IP(s) Allowlist | Vendor webhook IPs when you control the edge | 403 the rest |
| Ignore Bots | On | Link-preview GETs should not start fulfillment |
| Respond | Immediately, then process | Stripe and GitHub both punish slow 2xx |
| Path | Keep the random token | Guessable paths are not a control |
n8n documents a 16MB webhook payload cap (N8N_PAYLOAD_SIZE_MAX on self-host). That is a DoS knob, not a security control. A 16MB JSON blob that fails HMAC should never reach the CRM node — reject at the Code node.
Test vs production is a security setting. n8n registers the test URL when you click Listen; it registers the production URL when you publish. Mixing them is how a staging secret verifies a live event, or how a leftover test webhook stays public after the project ships.
Cloud vs self-hosted does not change the application checks. Cloud: you still verify signatures and scope tokens; the vendor patches the platform. Self-hosted: you also patch n8n, lock the admin UI, restrict who can create public webhooks, and watch egress. Neither hosting choice is a signature.
Webhook security is wasted if anyone at the company can edit production workflows.
- Limit who can publish production workflows
- Separate editor access from credential access when the edition allows it
- Review sudden changes to auth nodes
- Disable unused public webhook triggers
Your threat model includes a stolen laptop session, not only anonymous internet POST traffic.
What fails when you skip one of these layers?
The failure we see is not theoretical theater. It is a specific skip.
Unsigned production URL. Someone POSTs { "email": "victim@client.com", "status": "paid" }. The workflow creates the CRM row and fires the “welcome” sequence. Cost: cleanup, plus the conversation where you explain that the form was never the door.
Signature on parsed JSON. Stripe and GitHub start failing “randomly.” You disable verification to “unblock launches.” A week later the URL is in a ticket. You have no door.
HMAC on, no replay store. A captured invoice.paid from 90 seconds ago is still inside Stripe’s 5-minute window. It applies twice. Finance spends the afternoon reconciling. The idempotency post is the fix; this post is why the signature was not enough.
Allowlist only. You copied Stripe’s API IPs. Webhooks start 403ing because they come from the webhook list. Someone “fixes” it by blanking the allowlist and leaving HMAC for later.
Founder OAuth. A valid GitHub push event runs a workflow whose token can delete the org. The event was real. The blast radius was a choice.
| Skip | Symptom | First fix |
|---|---|---|
| No HMAC | Mystery CRM rows | Verify raw body, reject mismatch |
| HMAC on parsed JSON | Intermittent 400s from the vendor | Enable Raw Body |
| No timestamp / delivery id | Duplicates that “shouldn’t be possible” | Replay window + idempotency |
| Wrong IP list | Vendor deliveries 403 | Use webhook / hooks ranges |
| Secret in the path | Leak in one screenshot | Rotate, then move the secret |
| Admin-wide token | One event, outsized damage | Re-issue a scoped principal |
If you only have time for one improvement this week, turn on raw-body HMAC and a duplicate test in staging. That loop beats another connector.
How do you test a webhook before it sees live money?
Fifteen minutes in staging prevents a public incident.
Use the vendor’s tools, not a hand-built JSON file you signed with the wrong secret.
| Check | Expect | Tool |
|---|---|---|
| POST with no signature | Reject | curl / n8n test URL |
| POST with a garbage header | Reject | Same |
POST with an old Stripe t= | Reject | Mutate t= without a matching v1 — must fail |
| POST with GitHub test vector | Pass | Secret / payload pair from GitHub’s docs |
| Same valid event twice | One business apply | Your idempotency store |
| Stripe CLI vs Dashboard secret | Fail if crossed | Webhook quickstart |
| Error path | No secret in Slack / email | Trigger a deliberate fail |
GitHub documents the exact test vector. If your implementation cannot produce sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 for It's a Secret to Everybody + Hello, World!, you are not ready for a real repo.
Stripe’s CLI (stripe listen) is the right local loop. Do not verify those events with the Dashboard endpoint secret.
- Unsigned reject
- Bad signature reject
- Stale timestamp reject (Stripe)
- Duplicate delivery id is a no-op
- Logs show execution id, event id, verify result — not the secret, not a full PII dump
If any item fails, keep the production trigger disabled.
What do you do when a signing secret leaks?
Speed beats a perfect timeline.
- Roll the secret at the vendor. Stripe: Workbench → endpoint → Roll secret. GitHub: edit the webhook secret.
- Update n8n credentials / env. For Stripe, dual-accept during the up-to-24-hour overlap if you delayed expiry.
- Invalidate the old secret as soon as both sides agree.
- Review execution history for volume spikes, unknown event types, or side effects you did not schedule.
- Quarantine suspicious CRM / finance rows.
- Write the postmortem even if nothing applied.
| Symptom | Likely cause | Move |
|---|---|---|
| Valid signatures you did not expect | Secret or URL leak | Rotate, then audit applies |
| Sudden verify failures | Wrong env secret, or a roll you did not deploy | Check test vs live vs CLI |
| 403s from the vendor | Allowlist drift | Refresh Stripe webhook IPs or GitHub hooks |
| Duplicates after a leak response | Retries during the incident | Idempotency store, not a second rotate |
If one n8n hosts multiple clients: separate credentials, separate webhook paths, never let client A payloads write with client B tokens. Shared automation infrastructure without that split is a mapping bug away from a breach.
What belongs on the production checklist?
Print this next to the workflow. If any line is unchecked, the trigger stays off.
- HMAC verified on the raw body (Stripe
v1or GitHub SHA-256) - Constant-time compare
- Timestamp / replay window enforced where the vendor provides one
- Event or delivery id stored before side effects
- Secrets in credential store, not source text
- Test and live secrets are different objects
- Least-privilege tokens on every side-effect node
- Event-type allowlist
- IP allowlist uses webhook /
hooksranges, and someone owns the refresh - Fast
2xx(Stripe: before heavy work; GitHub: under 10 seconds) - Error alerts without secret or full-PII leakage
- Rotation owner named
- Unused test webhooks disabled
- Admin publish rights limited
Annual pass, or after any incident: rotate signing secrets, re-check scopes, remove unused public webhooks, re-run the staging checks, confirm redaction still holds.
Security is a calendar item, not a one-time setup screen.
Unsigned webhooks turn your CRM into a public write API. Treat them accordingly.
FAQ
How do I secure n8n webhooks?
Verify the provider HMAC on the raw body, keep secrets in credentials, separate test and live endpoints, allowlist the vendor’s webhook IPs when you control the edge, and use least-privilege tokens on side effects. Only then run business logic behind an idempotency key and a schema check. n8n Header auth is an extra gate, not a replacement for Stripe or GitHub signatures.
What is webhook signature verification?
It is a cryptographic check that the payload was sent by the vendor who holds the shared secret. Stripe puts an HMAC in Stripe-Signature; GitHub puts one in X-Hub-Signature-256. If the signature does not match the raw bytes, reject the request before any CRM or payment node runs.
Is a hidden URL enough protection?
No. URLs leak from tickets, screenshots, HAR files, and execution logs. Treat an unguessable path as a bonus layer. GitHub’s docs say not to put secrets in the payload URL — use a webhook secret and verify it.
Should webhooks be behind a VPN?
Sometimes, for private enterprise callbacks where both sides sit on the same network. Most SaaS vendors need a public HTTPS endpoint, so TLS, HMAC, replay controls, and least privilege do the work. IP allowlists help when the vendor publishes stable webhook egress, as Stripe and GitHub do.
What do I log?
Log execution id, event or delivery id, verification result, and the business identifiers you need to reconcile. Do not log signing secrets, OAuth tokens, or full payloads that are all PII. Auth failures can be counted without storing attacker bodies forever.
How does this relate to dead-letter queues?
Auth failures are noise and probes — count them, alert on spikes, and drop the body. Business-logic failures that happen after a valid signature belong in a parked-failure path with enough context to replay safely, minus secrets. The door and the recovery lane are different jobs.
CTA
If your webhook trusts anyone who can POST JSON, fix that before you add another integration.
Harden the door, then build the path. Read the Production n8n handbook, and use the automation lane or book an automation audit for a production security pass on the workflows that already move money.
What questions does this article answer?
- How do I secure n8n webhooks?
- Verify the provider HMAC on the raw body, keep secrets in credentials, separate test and live endpoints, allowlist the vendor's webhook IPs when you control the edge, and use least-privilege tokens on side effects. Only then run business logic behind an idempotency key and a schema check. n8n Header auth is an extra gate, not a replacement for Stripe or GitHub signatures.
- What is webhook signature verification?
- It is a cryptographic check that the payload was sent by the vendor who holds the shared secret. Stripe puts an HMAC in `Stripe-Signature`; GitHub puts one in `X-Hub-Signature-256`. If the signature does not match the raw bytes, reject the request before any CRM or payment node runs.
- Is a hidden URL enough protection?
- No. URLs leak from tickets, screenshots, HAR files, and execution logs. Treat an unguessable path as a bonus layer. GitHub's docs say not to put secrets in the payload URL — use a webhook secret and verify it.
- Should webhooks be behind a VPN?
- Sometimes, for private enterprise callbacks where both sides sit on the same network. Most SaaS vendors need a public HTTPS endpoint, so TLS, HMAC, replay controls, and least privilege do the work. IP allowlists help when the vendor publishes stable webhook egress, as Stripe and GitHub do.
- What do I log?
- Log execution id, event or delivery id, verification result, and the business identifiers you need to reconcile. Do not log signing secrets, OAuth tokens, or full payloads that are all PII. Auth failures can be counted without storing attacker bodies forever.
- How does this relate to dead-letter queues?
- Auth failures are noise and probes — count them, alert on spikes, and drop the body. Business-logic failures that happen **after** a valid signature belong in a parked-failure path with enough context to replay safely, minus secrets. The door and the recovery lane are different jobs.
Last reviewed
Automation
Automation After the show is not you at 1 a.m.
Post-show onboarding — thank-you, join path, merch nudge — belongs in a human-gated n8n rail, not your thumb at load-out.
Automation Paperwork that is not the plant
Invoice and PO matching, intake, and support triage in n8n with Metrc fences — the paperwork operators hate, not a menu widget.
Automation Saturday still books — the missed-call rail for trades
A missed-call text-back that routes zip and books a slot beats voicemail and Saturday desk coverage you cannot keep staffed. If a kid is cheaper, say so.
Automation Why doesn’t worker concurrency cap my n8n sub-workflows
Worker concurrency does not cap n8n sub-workflows. Each Execute Workflow child is a new execution the production limit skips, usually on the parent worker.
Will's Journal in your inbox.
What I learned this week building for shops, floors, and houses.
You're on the list.
Sign-up failed — try again.
By subscribing, you agree to the Privacy Policy.