Practice · Payments · Card 3
The user lands on /success. The code marks the order paid. What's wrong?
A controller that trusts the redirect. The bug is invisible 99% of the time. The 1% is the bug.
The code
# routes.rb
get "/orders/:id/success", to: "orders#success"
# orders_controller.rb
def success
@order = Order.find(params[:id])
@order.update!(status: "paid", paid_at: Time.current)
GrantAccessJob.perform_later(@order.id)
OrderMailer.with(order: @order).receipt.deliver_later
end The question
The intent: "after the user pays at Stripe, they redirect to this URL, and we mark them paid." A user finds an edge case where they get access without the money clearing. What's the bug, and how would you fix it?
Take a moment. What does the redirect actually prove? What channel can you trust to know the money moved?
The bug
The order is marked paid based on the user reaching this URL, not on the payment actually clearing. The redirect proves only that the user's browser made it back to your site. It does not prove the payment cleared.
Edge cases that trigger the bug:
- The user hits this URL by sharing it.
- The user gets redirected back after an aborted 3DS challenge that ultimately failed.
- An attacker crafts the URL knowing the path.
- The user uses a browser extension that messes with redirects.
The fix
The redirect should land the user on a "we're confirming your payment" status page. The webhook handler — signed, server-to-server, retried until you ack — is what marks the order paid. The status page reacts to the order status flipping via Turbo Streams, polling, or Cable.
Two channels carry information about a payment. One is for UX, one is for state. Don't mix them.
Wrong takes worth noticing
- "Reorder the mailer and the access grant." Reordering side effects doesn't address the root issue. The order is still being marked paid based on the redirect.
- "Use
find_byinstead offind." A code style choice, not the payment bug. Both forms still trust the redirect. - "Require the user to be logged in." Helps with one attack vector (random URLs) but doesn't fix the core bug — the redirect proves presence, not payment.
The principle
The client redirect is a UX signal. The webhook is the state machine. They look similar (both deliver a payment_intent ID) and they mean very different things. Senior code uses each for what it's for: the redirect lands on a "processing" page; the webhook updates the order; the status page reacts.
Theory
Full walkthrough at Payments · Async Confirmation: Trust the Webhook.