Webhook returning a 401 or 403 falls into a small number of buckets. Most of the time it's not even a signature problem — it's a middleware or framework default rejecting the request before your code runs. This is the diagnostic checklist we use, in the order we use it.
First, separate 401 from 403
They mean different things and the fix is different.
401 Unauthorized: the server got the request, looked at the credentials (or signature), and didn't accept them. Your handler probably ran, computed an expected signature, and rejected.
403 Forbidden: the server got the request and refused to process it for some other reason. Often the request never reached your handler — a middleware or framework default sent the 403.
Open your tunnel's request capture and look at the response. If you can see your handler's response body ("invalid signature"), it's a 401 from your code. If the response is generic and your handler logs show nothing, the framework rejected it before your handler ran.
The five most likely causes
1. CSRF middleware (Django, Rails, Laravel)
Default CSRF protection rejects POSTs without a session token. Webhook providers don't send one. Symptoms: 403, generic response body, no handler logs.
Fix: exclude the webhook route from CSRF protection. In Django, @csrf_exempt. In Rails, skip_before_action :verify_authenticity_token, only: [:webhook]. In Laravel, add the path to VerifyCsrfToken::$except. The Django webhook guide walks through the Django version end to end.
2. Auth middleware applied too broadly
You added auth middleware globally (JWT, session check, API key requirement) and the webhook route inherited it. The provider doesn't send your auth header, so the middleware sends a 401 before your handler runs.
Fix: exclude the webhook path from your auth middleware. Webhook authentication is the signature, not whatever scheme protects the rest of your API.
3. Signature verification failing
Your handler ran, computed an expected signature, and didn't match. Five sub-causes in roughly descending order of frequency:
- Body parsed by middleware before verification ran (raw body is gone).
- Wrong encoding (hex vs base64). See the signature verification guide.
- Wrong secret (test vs live, dashboard vs CLI, env file vs runtime).
- Timestamp too old (the signature is valid but stale — likely you're testing with an old replayed payload).
- Trailing whitespace in the secret loaded from an env file.
4. Wrong tunnel URL registered
You restarted your tunnel and the URL rotated, but the provider dashboard still has the old one. Symptom looks like a 401 because the request never reached you — but it's actually a different request reaching a different server (often the previous tunnel session, which now refuses or returns 401).
Fix: confirm the URL in the provider dashboard matches your current tunnel session. If you need a stable URL, look at named tunnels or a reserved subdomain.
5. CORS, content-type, or method restrictions
Less common for webhooks but possible. If your route only accepts application/json and the provider sends application/x-www-form-urlencoded (Twilio, for example), some frameworks 415 — but a misconfigured one might 403. Or your route is registered for GET and the provider POSTs.
The 90-second diagnostic flow
This is the order we go through:
- Read the response body. If it has your handler's words in it, the request reached the handler. Move to signature debugging. If it's a generic framework error page, the request didn't reach the handler. Move to middleware debugging.
- Check handler logs. Are any log statements from your handler firing? Confirms whether the request reached you.
- Check the registered URL. Open the provider dashboard. Confirm the URL matches your current tunnel. Confirm it points to the right path.
- Compare incoming headers. Tunnel capture shows you the exact headers the provider sent. Compare to what your code reads. Headers are case-insensitive but the way you access them differs by framework —
request.headers.get('Stripe-Signature')in some,request.META['HTTP_STRIPE_SIGNATURE']in others. - Verify the body is raw at signature time. Print the length of the body just before you compute the signature. If it's zero or surprisingly small, a middleware ate it.
- Double-check the secret. Compare the env variable in your runtime to the dashboard.
console.log(process.env.WEBHOOK_SECRET.length)— does the length match what the dashboard shows?
In our experience, steps 1 and 5 catch 80% of cases inside the first two minutes.
Capture the failing request
The single most useful tool here is a tunnel with request capture and replay. You don't need to wait for the provider to retry — you replay the captured request against your local handler while you debug. Each attempt is instant.
If you're using a tunnel that doesn't capture requests, you're debugging with one hand tied behind your back. Switching to one that does (or running tcpdump, or putting nginx in front of your dev server) pays for itself the first time you save an hour.
What 401 looks like from each provider
- Stripe: 401 from your code means signature verification failed. Stripe dashboard shows the delivery as failed and includes your response body.
- GitHub: if your handler returns 401, GitHub marks the delivery as failed and retries. Recent deliveries page shows the response.
- Shopify: 401 from a webhook handler is fine for security but Shopify will retry. After 19 consecutive failures over 48 hours, Shopify disables the subscription.
For the broader webhook-debugging context, see how to debug webhooks locally. Join the PortPreview waitlist for a tunnel with capture built in.