TA AzeezCode

Case studies

Secure payment webhooks: validating PayPal, Stripe and Flutterwave callbacks

A secure payment integration case study covering server-side verification, webhook authenticity, payment-state validation, duplicate processing, secrets management and safe failure handling.

high severity Webhooks Payments PHP API Security Secure Development

Security problem

Payment integrations create a security-sensitive boundary between an application and an external payment provider.

A dangerous implementation assumes that a request reaching a webhook or payment-return endpoint must have come from the payment provider.

That assumption is unsafe.

Webhook URLs can be discovered, requests can be reproduced, browser parameters can be modified and attackers can send their own HTTP requests directly to application endpoints.

The application therefore needs independent server-side evidence before changing an invoice or payment record to a trusted paid state.

Project context

This case study comes from payment integration work in a PHP business application.

The application supports payment workflows involving providers including:

  • PayPal
  • Stripe
  • Flutterwave

Each provider has its own verification mechanism and API behaviour, so verification should follow the provider's documented security model rather than forcing every gateway through one generic signature algorithm.

The security objective remains consistent:

A user-controlled request must never be sufficient by itself to convince the application that money was successfully received.

Threat model

The payment flow was considered from the perspective of an attacker who can control requests sent to public application endpoints.

Relevant questions include:

  • Can the webhook endpoint be called directly?
  • Can payment status be changed by modifying request parameters?
  • Can an amount be changed before the request reaches the server?
  • Can a payment reference from another transaction be reused?
  • Can a webhook request be forged?
  • Can the same successful event be processed more than once?
  • Does the application trust browser-side success messages?
  • Are API credentials exposed in source code or responses?
  • Do failed payment requests expose sensitive debugging information?

These questions help separate the user-visible payment flow from the server-side security decision.

Trust boundary

The browser is not a trusted source of payment truth.

For example, a request such as:

/payment/success?status=paid&amount=500

must not be enough to mark an invoice as paid.

Values supplied through:

  • query parameters
  • form fields
  • JavaScript
  • hidden fields
  • redirect URLs
  • client-controlled JSON

must be treated as untrusted.

The trusted payment decision must happen server-side.

Secure webhook processing

A secure webhook flow follows a defensive sequence.

1. Receive the webhook

The application accepts the inbound request on the provider-specific webhook endpoint.

At this point the request is still untrusted.

2. Verify authenticity

The application verifies the webhook using the mechanism required by that payment provider.

Depending on the gateway, this may involve:

  • signature verification
  • provider verification APIs
  • webhook secrets
  • signed headers
  • provider-specific cryptographic validation

Verification must happen before the webhook is allowed to perform sensitive business actions.

3. Parse only after establishing trust

The application extracts the event information required for processing while avoiding unnecessary trust in client-controlled fields.

Where a provider's signature mechanism depends on the original request body, verification must use the representation required by that provider.

4. Validate the payment against application records

A valid provider message does not automatically mean that every value should be accepted blindly.

The application should correlate the event with its own payment or invoice record and verify relevant properties such as:

  • transaction reference
  • expected invoice
  • expected amount
  • expected currency
  • provider payment status

The payment event must make sense in the application's own business context.

5. Update payment state server-side

Only after verification should the application update internal payment state.

The browser should never decide whether an invoice is paid.

Provider-specific verification

A key lesson from implementing multiple gateways is that webhook security should not be oversimplified.

PayPal, Stripe and Flutterwave do not necessarily authenticate webhook events in exactly the same way.

The application therefore separates gateway-specific verification from the common payment business logic.

Conceptually:

interface PaymentGateway
{
    public function verifyWebhook(Request $request): bool;

    public function handleWebhook(Request $request): PaymentResult;
}

The exact implementation can differ for each provider while the application maintains one important rule:

UNVERIFIED EVENT
      |
      v
VERIFY WITH PROVIDER-SPECIFIC LOGIC
      |
      +---- invalid ----> reject safely
      |
      v
VALIDATED EVENT
      |
      v
CHECK INTERNAL PAYMENT RECORD
      |
      v
UPDATE PAYMENT STATE

This makes the trust decision explicit.

Signature verification

Where a provider uses cryptographic signatures, verification must use the provider's expected algorithm and signed data.

A simplified HMAC example demonstrates the principle:

$payload = $request->getContent();

$expected = hash_hmac(
    'sha256',
    $payload,
    $webhookSecret
);

if (!hash_equals($expected, $receivedSignature)) {
    http_response_code(400);
    exit;
}

This is an illustrative pattern rather than a claim that every payment provider uses this exact construction.

The provider's official verification specification must always determine the actual implementation.

Why constant-time comparison matters

When manually comparing cryptographic values in PHP, hash_equals() is preferable to a normal string equality comparison.

For example:

hash_equals($expected, $received);

is designed for timing-safe comparison of known strings.

However, correct comparison alone does not make a webhook secure. The correct message, secret, algorithm and provider-specific verification procedure must also be used.

Server-side payment verification

One of the most important controls is separating the browser's payment experience from the application's payment decision.

A customer may legitimately return to:

/payment/success

after completing payment.

That page can tell the user that processing succeeded or is being verified.

It should not independently create the trusted payment result simply because the browser reached the success URL.

The application should use verified server-side information before recording the transaction as successful.

Amount validation

A valid payment event must also correspond to what the application expected.

Conceptually:

if ($verifiedAmount !== $invoice->outstandingAmount()) {
    // Do not mark the invoice as fully paid.
}

The same principle applies to currency and transaction references.

This protects against logic errors where a technically valid payment is applied incorrectly to another business record.

Duplicate processing

Payment providers may legitimately deliver webhook events more than once.

Therefore, duplicate delivery should not create duplicate business effects.

Sensitive actions should be designed so that processing the same payment notification repeatedly does not:

  • record the payment twice
  • reduce an outstanding balance twice
  • generate duplicate receipts
  • trigger duplicate fulfilment
  • execute the same downstream business action repeatedly

This is both a reliability and security concern.

Payment state

Payment processing should use controlled state transitions.

For example:

PENDING
   |
   v
VERIFIED
   |
   v
PAID

Invalid or unverifiable events should not be able to jump directly into a trusted state.

The application's database remains the authoritative record of the business workflow.

Secrets management

Payment credentials and webhook secrets must not be hard-coded into publicly committed source files.

Environment configuration is used for sensitive provider values.

For example:

PAYMENT_SECRET=...
WEBHOOK_SECRET=...

The actual production values are excluded from version control.

A repository may contain an example configuration describing required variables without containing the real secrets.

Safe failure behaviour

Invalid or unverifiable webhook requests should fail safely.

The application should not return:

  • API secrets
  • signature material
  • stack traces
  • database errors
  • internal file paths
  • provider credentials
  • detailed production debugging information

Public responses should remain controlled while useful diagnostic information is handled server-side.

Logging considerations

Payment logging requires particular care.

Logs can be useful for investigating failed callbacks and payment-state problems, but sensitive information should not be unnecessarily recorded.

Credentials, secrets and authentication tokens should never be written to application logs.

Where possible, logging should focus on operational identifiers and outcomes rather than entire sensitive request payloads.

Security testing

A payment integration should be tested with both legitimate and hostile request conditions.

Useful negative tests include:

Forged webhook

Send a request that has not passed the provider's authenticity verification.

Expected result:

Rejected
No trusted payment-state change

Modified amount

Attempt to alter client-controlled payment information.

Expected result:

Server-side expected values remain authoritative

Invalid transaction reference

Supply a reference that does not correspond to the expected internal payment record.

Expected result:

Payment not applied to unrelated invoice

Duplicate callback

Process the same successful payment notification more than once.

Expected result:

No duplicate business effect

Direct success-page request

Visit or reproduce the browser success URL without completing a valid provider transaction.

Expected result:

Browser navigation alone cannot create a paid invoice

Retesting

After remediation or payment-integration changes, the original negative tests should be repeated.

A successful retest should demonstrate that:

  • legitimate provider events continue to work
  • forged or unverifiable requests cannot alter trusted payment state
  • client-controlled values cannot override server-side payment records
  • duplicate notifications do not create duplicate business effects
  • invalid references do not affect unrelated records
  • payment secrets remain outside public source code
  • failures do not expose sensitive implementation information

Security fixes should preserve the legitimate payment workflow while closing the trust-boundary weakness.

Application security lesson

Payment security is not achieved simply by integrating a well-known payment provider.

The application still owns the security decisions around:

  • authentication of callbacks
  • authorisation of business actions
  • transaction correlation
  • amount validation
  • duplicate processing
  • state transitions
  • secrets management
  • error handling

The payment provider protects its infrastructure.

The application developer remains responsible for securely integrating that provider into the application's own trust model.

Development lesson

Building payment integrations reinforced an important principle in my application security work:

Never allow the client to make a security decision that the server can independently verify.

The same principle applies beyond payments to authentication, access control, password resets, file processing and other security-sensitive workflows.

Authorisation

This work was carried out under written authorisation or inside an authorised training environment. All evidence has been sanitised.

Continue reading

Related case studies

high severity Access control

IDOR: missing server-side authorisation in an invoice workflow

An authenticated user could modify an invoice identifier and attempt to access another customer's resource. The remediation enforced ownership at the database query layer and was verified with positive and negative authorisation tests.

IDOR Broken Access Control Authorisation PHP

5 min read

high severity SSRF

SSRF in a server-side document import feature

An authorised security lab examining how a user-controlled URL can cause a web server to make unintended requests to internal or restricted destinations, followed by layered remediation and retesting.

SSRF Web Security PHP Input Validation

9 min read

high severity XXE

XXE in a legacy XML import parser

A supplier feed parser resolved external entities, exposing server-side file contents. Remediated by disabling entity loading, switching to a safe parser configuration and validating against a schema.

XXE Secure parsing Legacy code Retested

2 min read