DEV Community

Gumbo Sveins
Gumbo Sveins

Posted on

Auth Middleware Should Not Guard Payment Webhooks

You ship Clerk (or NextAuth, or a custom session matcher). Every /api/* route now requires a logged in user. Checkout still works in the browser. Polar and Stripe dashboards show paid events. Your entitlement table stays empty.

The provider never reached your handler. Middleware answered first with a redirect to sign in. Signature verification never ran. Retries keep bouncing off the same gate.

This is a configuration bug that looks like a payment bug.

What the provider actually hits

A payment webhook is a server to server POST. There is no browser cookie. There is no Clerk session. There is no NextAuth JWT. The only proof you get is the provider signature over the raw body.

If your matcher treats /api/webhooks/polar like a private page, the request never becomes req.text() plus constructEvent. You get 307/401 HTML instead of a verified event.

Checkout can still succeed. The buyer browser flow does not need your middleware exception. Entitlements do.

The usual stack that causes it

  1. A catch all middleware.ts that protects (dashboard) and also matches /api/(.*).
  2. A public allow list that covers /sign-in and /sign-up but forgets webhook paths.
  3. A later rename from /api/webhook to /api/webhooks/polar without updating the allow list.
  4. Preview and production sharing the same matcher while only one env has the live signing secret.

Any one of those is enough to leave paid rows in the provider UI and locked rows in your app.

Fix the matcher, not the product

Keep webhook routes public at the edge. Authenticate them with crypto, not sessions.

Example shape with Clerk style matchers:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher([
  "/",
  "/sign-in(.*)",
  "/sign-up(.*)",
  "/api/webhooks(.*)",
  "/api/webhook(.*)",
]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});
Enter fullscreen mode Exit fullscreen mode

Adjust path names to match your repo. Polar, Stripe, Resend, and GitHub should all sit on that public list if they POST signed events.

Then inside the route handler:

  1. Read the raw body with await req.text() (or arrayBuffer()).
  2. Verify the signature with the Production scoped secret.
  3. Persist the event id with a unique constraint.
  4. Return 2xx quickly.
  5. Grant entitlements from a worker or a second durable step.

Do not put grant logic behind auth(). The provider cannot log in.

How to confirm in five minutes

  1. Trigger a sandbox paid event (Polar CLI, Stripe CLI, or a tiny test checkout).
  2. Watch Vercel Runtime Logs for POST /api/webhooks/....
  3. If you see a 307/401 to a sign in URL, the matcher is still eating the request.
  4. If you see 400 on signature, the route is reachable and your secret or raw body handling is wrong (different bug).
  5. If you see 2xx and still no entitlement, fix idempotent grant logic next.

Provider delivery history is the other half of the story. A wall of 3xx/4xx on the webhook URL means middleware or DNS. A wall of 2xx with an empty database means your handler.

Pair with the rest of the go live list

This sits next to the earlier traps:

  1. Expired checkout sessions are abandoned carts, not declines.
  2. Preview hostnames are not stable webhook endpoints.
  3. Sandbox tokens and live secrets must not mix.
  4. Access tokens do not belong in NEXT_PUBLIC_ names.

Ship the matcher exception before you blame Polar, Stripe, or your ORM.

Soft sell

If you want a small lab to practice sandbox and live webhook shapes offline, Dual Mode Webhook Lab is $14.

If you want a longer go live checklist for Next.js on Vercel with Polar checkout, webhook, and entitlement notes, the Next.js / Vercel Production Launch Kit is $19.

Support: gumbosveins@gmail.com

More in this series

  1. The Next.js / Vercel production env mistakes that break launches
  2. Polar sandbox vs live webhooks: why checkout works and entitlements do not
  3. Build a Vercel env matrix offline before you merge
  4. Stop pasting Polar webhook secrets into online signature debuggers
  5. The Checkout Is Not the Entitlement
  6. Polar Access Tokens Do Not Belong in NEXT_PUBLIC_
  7. Expired Polar Checkout Sessions Are Not Failed Payments
  8. Preview Hostnames Are Not Webhook Endpoints

Top comments (1)

Collapse
 
jescalan profile image
Jeff Escalante

Good post - I would just make sure to emphasize that with webhooks you gotta be very sure to have them come in signed and verify the signature it they are going to be open like this