fix(payments): trust Host header for checkout return URLs, not req.nextUrl.origin (#479) - #491
Merged
Merged
Conversation
…xtUrl.origin (#479) Live PayPal sandbox QA on the code-academy tenant subdomain surfaced a bug independent of any provider: both the unified checkout route and the PayPal capture-return route derived the buyer's return origin from req.nextUrl.origin in development, which resolves to the Next.js dev server's own bind address (localhost:PORT) rather than the incoming Host header. A PayPal one-time purchase built its return_url from that origin, sending the buyer back to localhost:3000/checkout/success after approval — a different origin than their tenant-subdomain session cookie, bouncing them to /auth/login despite the purchase having succeeded (verified via webhook_events and the transactions row: payment completed correctly, only the redirect was wrong). Both routes now derive the origin from x-forwarded-host / host headers first, falling back to req.nextUrl only if neither is present — the same pattern already used in app/api/stripe/connect/route.ts and publicRedirectUrl() in proxy.ts. Confirmed live: a fresh PayPal one-time purchase and a PayPal subscription both completed and landed the buyer back on the correct tenant subdomain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYWqTKnHDLQC1hbithnsb3
Owner
Author
Visual verificationA fresh PayPal sandbox one-time purchase after the fix, showing the buyer correctly landed back on the tenant subdomain ( Before the fix, this same flow redirected the buyer to |
guillermoscript
marked this pull request as ready for review
July 23, 2026 19:55
30 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What & why
Found during the live-credentialed portion of #479 (PayPal sandbox QA follow-up to #466 / PR #478). Independent of the categoryMap fix in #490 — this is a checkout-redirect bug, not a settings-page bug, and it affects every non-Stripe provider that round-trips through a hosted checkout, not just PayPal.
The bug
app/api/payments/checkout/route.tsandapp/api/payments/paypal/capture/route.tsboth built the buyer's return origin like this:req.nextUrl.origindoes not reflect the incomingHostheader in development — it resolves to the Next.js dev server's own bind address (localhost:PORT), regardless of which tenant subdomain the request actually came in on.Live reproduction on the
code-academytenant: a PayPal one-time checkout built itsreturn_urlfromreq.nextUrl.origin, so after the buyer approved the payment on PayPal's sandbox, they were redirected tolocalhost:3000/checkout/success?transactionId=…instead ofcode-academy.lvh.me:3000/checkout/success?…. Since their session cookie is scoped to the tenant subdomain,localhosthad no session, and the buyer was bounced to/auth/login— even though the payment itself succeeded (confirmed via thetransactionsrow flipping tosuccessfuland thePAYMENT.CAPTURE.COMPLETEDwebhook processing correctly inwebhook_events). The purchase was never actually lost, but the buyer's browser landed on a broken, illegitimate-looking page after paying.The same pattern already exists correctly in
app/api/stripe/connect/route.ts, which derives the origin fromx-forwarded-host/hostheaders directly rather than trustingreq.nextUrl.originin development.The fix
Both routes now resolve the origin the same way
stripe/connect/route.tsdoes:No environment-conditional branching — this resolves correctly in both dev and production, since the
Hostheader always carries the actual authority the browser dialed.Test plan
npx tsc --noEmit→ clean.npx eslinton both changed files → 0 errors.npx vitest run→ full suite, 218/218 pass (no existing tests target these specific route handlers; nothing regressed).code-academytenant subdomain, both before and after the fix:localhost:3000/checkout/successafter PayPal approval, bounced to/auth/loginon a different origin than their session.code-academy.lvh.me:3000/dashboard/student/courses/…correctly, with the enrollment created and thePAYMENT.CAPTURE.COMPLETEDwebhook processed.subscription_status: active, correctprovider_subscription_id) and bothBILLING.SUBSCRIPTION.ACTIVATEDand the immediate renewal webhook processed.Screenshot
PayPal one-time checkout completing and landing back on the tenant subdomain (
code-academy.lvh.me:3000) after the fix — attached as a PR comment.