Hi, I noticed a possible reusable payment-header credit issue while reviewing the current source.
In routes/handler.go, an incoming payment header is treated as a lookup key for an existing purchase:
297: // tryExistingPayment checks if a client-provided X-Payment header corresponds to a valid, usable purchase.
302: func (h *PaidRouteHandler) tryExistingPayment(gCtx *gin.Context, route *PaidRoute) (usedExistingCredit bool, proceedToNewPayment bool) {
303: paymentHeaderName := getPaymentHeaderNameForRoute(route)
304: clientPaymentHeader := gCtx.GetHeader(paymentHeaderName)
305: if clientPaymentHeader == "" {
306: return false, true
307: }
314: existingPurchase, err := h.purchaseService.GetPurchaseByRouteIDAndPaymentHeader(gCtx.Request.Context(), route.ID, clientPaymentHeader)
If a purchase record is found, the code consumes one local credit and continues toward proxying without re-verifying or re-settling the original payment proof:
334: if existingPurchase.CreditsUsed >= existingPurchase.CreditsAvailable {
335: h.logger.Info("Existing purchase (via header) has no credits left. Proceeding to new payment.",
337: return false, true
340: // Credits are available, attempt to use one.
344: errIncrement := h.purchaseService.IncrementCreditsUsed(gCtx.Request.Context(), existingPurchase.ID)
351: // Successfully used a credit from an existing purchase.
352: h.logger.Info("Successfully used a credit from existing purchase via payment header.",
The database query uses only the route id and the raw stored payment header:
59: -- name: GetPurchaseByRouteIDAndPaymentHeader :one
60: SELECT * FROM purchases
61: WHERE paid_route_id = $1 AND payment_header = $2
62: ORDER BY created_at DESC LIMIT 1;
64: -- name: IncrementPurchaseCreditsUsed :exec
65: UPDATE purchases
66: SET credits_used = credits_used + 1, updated_at = $2
67: WHERE id = $1 AND credits_used < credits_available;
For new purchases, the same raw header is persisted together with local credit state:
427: paymentPayloadJSON, settleResponseJSON := x402.Payment(gCtx, priceFloat, paymentAddress,
446: // If we get here, payment verification within x402.Payment succeeded.
447: paymentHeaderForNewPurchase := gCtx.GetHeader(getPaymentHeaderNameForRoute(route))
448: err = h.savePurchaseRecordJSON(gCtx.Request.Context(), route, paymentAddress, paymentPayloadJSON, settleResponseJSON, paymentHeaderForNewPurchase)
877: purchase := &purchases.Purchase{
881: Price: route.Price,
883: CreditsAvailable: route.Credits,
884: CreditsUsed: 1, // If we create a purchase record, we know it's a new payment. (1 use already)
887: PaidRouteID: route.ID,
888: PaidToAddress: paymentAddress,
890: PaymentHeader: paymentHeader,
891: PaymentPayload: paymentPayloadJSON,
892: SettleResponse: settleResponseJSON,
The normal fresh-payment path does settle before storing the purchase. For example, x402/payment.go settles successfully before returning:
183: settleResponse, err := facilitator.Settle(c.Request.Context(), decodedPayload, requirementsJSON)
184: if err != nil || settleResponse == nil || !settleResponse.Success {
191: respondPaymentRequiredV1(c, isWebBrowser, resource, amount, options, paymentRequirements, paymentRequirementsJSON, errMsg)
192: return nil, nil
204: c.Header("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleResponseJSON))
206: return paymentPayloadJSON, settleResponseJSON
The concern is specifically the later reuse path:
source: X-Payment / PAYMENT-SIGNATURE header
transform: lookup purchase by paid_route_id + raw header
sink: increment local credits_used and allow route access
If the raw header is copied, logged, shared, or replayed by another client, it may act as a bearer credit token until credits_available is exhausted. The lookup does not appear to bind reuse to the original payer identity, request context, client session, or a server-generated opaque entitlement id. This may be intended as multi-use credits, but it would be safer if the reusable artifact were a narrow server-issued entitlement rather than the original payment proof header.
Possible hardening directions:
- Store a server-generated purchase token for credit reuse instead of reusing the payment header itself.
- Bind reusable credits to the original payer, authenticated user, or client account where that identity exists.
- Treat the original payment proof as one-time settlement material and separate it from post-settlement entitlement state.
- Consider hashing stored proof material and avoiding raw proof reuse as an authorization credential.
I am reporting this as a potential issue rather than a confirmed exploit, since the intended product model may explicitly allow bearer-style prepaid credits for a route.
Hi, I noticed a possible reusable payment-header credit issue while reviewing the current source.
In
routes/handler.go, an incoming payment header is treated as a lookup key for an existing purchase:If a purchase record is found, the code consumes one local credit and continues toward proxying without re-verifying or re-settling the original payment proof:
The database query uses only the route id and the raw stored payment header:
For new purchases, the same raw header is persisted together with local credit state:
The normal fresh-payment path does settle before storing the purchase. For example,
x402/payment.gosettles successfully before returning:The concern is specifically the later reuse path:
If the raw header is copied, logged, shared, or replayed by another client, it may act as a bearer credit token until
credits_availableis exhausted. The lookup does not appear to bind reuse to the original payer identity, request context, client session, or a server-generated opaque entitlement id. This may be intended as multi-use credits, but it would be safer if the reusable artifact were a narrow server-issued entitlement rather than the original payment proof header.Possible hardening directions:
I am reporting this as a potential issue rather than a confirmed exploit, since the intended product model may explicitly allow bearer-style prepaid credits for a route.