DEV Community

Cover image for Trusting PayPal Webhooks the .NET Way
Peter Wurzinger
Peter Wurzinger

Posted on

Trusting PayPal Webhooks the .NET Way

This is the second of the two-part series where I share my insights on the PayPal API and C#/.NET:

  • Part I covered proper HTTP request authentication for the PayPal API
  • Part II demonstrates building ASP.NET Core endpoints that accept PayPal webhooks, verifying their integrity and why that matters

Disclaimer

AI was used to refine phrasing, correct errors, and structure the content. The technical content and implementation were created entirely by me.

Prerequisites

This post won't cover how to set up an application integration in PayPal or how to register a webhook - check the official docs for that. I also assume you know what a webhook is and what you want to use it for. This post will show you how to verify the integrity of incoming requests and why you should still be cautious.

I'm using C# 14 and .NET 10, but you can adapt the code to earlier versions with minimal changes and some additional NuGet packages. Even for .NET Framework 3.5. (Just kidding - get rid of that already.)

Spam, Lisa, and trust in messages

Hi. Your bank account has been involved in fraudulent activity. Further transactions have been suspended, please follow this link to reactivate it.

If I sent you this email, would you believe me and take it at face value? Probably not.

But let's say your personal bank advisor calls you on your cell phone:

Hi Peter, it's Lisa from Fells Wargo. Last night, someone in Bangkok attempted to use your debit card to pay for an adult online service subscription. Could you come over to change the card?

I would at least consider that plausible. And alarming. And obviously I'm not going to tell Lisa that I'm on vacation in Southeast Asia. cough

Jokes aside, why does the second message prompt me to go to my local branch and take action, while I dismissed the first message as spam, even though their content is essentially identical?
This is highly subjective, but there are clues:

  • You know your personal bank advisor, you recognize her voice on the phone, and she's listed as an employee on your branch's website
  • She knows your phone number and your name
  • She doesn't ask for immediate action, but for verification in person

There's metadata that builds trust in the sender - certain indicators that make you think, "Some son of a biscuit is trying to spend my money."

Trusting Lisa - and PayPal

PayPal webhooks provide those clues via message signatures. They append a signature as a header to the HTTP request, which you can use to verify the request's integrity.

First, you need to build the string transmissionId|timeStamp|webhookId|crc32:

  • transmissionId from the HTTP request header PAYPAL-TRANSMISSION-ID
  • timeStamp from the HTTP request header PAYPAL-TRANSMISSION-TIME
  • webhookId is the Webhook ID you get after registering a webhook
  • crc32 is the CRC32 checksum of the HTTP request body

After assembling that, you'll end up with a string that looks like this:

db49fb10-1343-11ef-ac58-e32457403f67|2024-05-16T05:19:23Z|0NH55953DH663215D|2904282587

Its UTF-8 binary representation is the message that was signed. Where do you get the signature? The header PAYPAL-TRANSMISSION-SIG is what you're looking for. Since I scratched my head over this for quite some time, let me rephrase:

The value of the HTTP header PAYPAL-TRANSMISSION-SIG is the signature of the binary representation of the UTF-8 string db49fb10-1343-11ef-ac58-e32457403f67|2024-05-16T05:19:23Z|0NH55953DH663215D|2904282587.

Now we have the message and its signature. To actually verify it, we also need:

  • the algorithm
  • the public key of the sender

For the algorithm, there's another header in the request called PAYPAL-AUTH-ALGO. So far I've only seen the value SHA256withRSA, but we'll support different hash algorithms as well.

The public key takes a bit more effort to obtain. The request header PAYPAL-CERT-URL points to a PayPal endpoint where you can download the certificate. This certificate contains the public key that we'll feed into the RSA verification.

Summing it up conceptually

That was a lot of theory, so let me sum it up in pseudocode:

verify(webhookId, request):
    transmissionId = request.header["PAYPAL-TRANSMISSION-ID"]
    transmissionTime = request.header["PAYPAL-TRANSMISSION-TIME"]

    message = "{transmissionId}|
               {transmissionTime}|
               {webhookId}|
               {crc32(request.Body)}"

    algorithm = request.header["PAYPAL-AUTH-ALGO"]
    certificate = http_get(request.header["PAYPAL-CERT-URL"])
    signature = request.header["PAYPAL-TRANSMISSION-SIG"]

    return RSA.verify(message, signature, algorithm, certificate.publicKey)
Enter fullscreen mode Exit fullscreen mode

Phew. Trusting Lisa was less complicated.

Finally some real-world code

Let's go. Luckily you don't have to implement the crypto stuff like CRC and RSA in .NET yourself - although for CRC32 you'll need the additional NuGet package System.IO.Hashing. And no, I don't have an idea why that is.

To support integrations beyond ASP.NET Core, I implemented a class that takes all the required inputs and the HTTP body as a Stream.

using System.IO.Hashing;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;

public sealed class PayPalWebhookVerifier(PayPalWebhookSignatureCertificateCache payPalSignatureCertificateCache)
{
  public async Task<bool> Verify(string webhookId,
                                 string authAlgo,
                                 string certUrl,
                                 string transmissionId,
                                 string transmissionSig,
                                 string transmissionTime,
                                 Stream stream,
                                 CancellationToken cancellationToken)
  {
    ArgumentException.ThrowIfNullOrEmpty(webhookId);

    ArgumentException.ThrowIfNullOrEmpty(authAlgo);
    if (!authAlgo.EndsWith("withRSA", StringComparison.OrdinalIgnoreCase))
      throw new NotImplementedException("Only RSA is supported as signature algorithm.");

    ArgumentException.ThrowIfNullOrEmpty(certUrl);
    var certificateEndpoint = new Uri(certUrl);
    if (!certificateEndpoint.Host.EndsWith("paypal.com", StringComparison.OrdinalIgnoreCase))
      return false;

    ArgumentException.ThrowIfNullOrEmpty(transmissionId);
    ArgumentException.ThrowIfNullOrEmpty(transmissionSig);
    ArgumentException.ThrowIfNullOrEmpty(transmissionTime);
    ArgumentNullException.ThrowIfNull(stream);

    using var certificate = await payPalSignatureCertificateCache.GetCertificateByCertUrl(certificateEndpoint, cancellationToken);
    if (certificate is null)
      return false;

    var crc32 = await GetCrc32(stream, cancellationToken);
    var message = Encoding.UTF8.GetBytes($"{transmissionId}|{transmissionTime}|{webhookId}|{crc32}");

    var messageSignature = Convert.FromBase64String(transmissionSig);

    return VerifySignature(message, messageSignature, certificate, authAlgo);
  }

  private static async Task<uint> GetCrc32(Stream stream, CancellationToken cancellationToken)
  {
    var crc32 = new Crc32();

    await crc32.AppendAsync(stream, cancellationToken);
    return crc32.GetCurrentHashAsUInt32();
  }

  private static bool VerifySignature(ReadOnlySpan<byte> message,
                                      ReadOnlySpan<byte> signature,
                                      X509Certificate2 certificate,
                                      string authAlgo)
  {
    using var rsa = certificate.GetRSAPublicKey()
                      ?? throw new InvalidOperationException("Certificate does not contain a public key.");

    var hashAlgorithmName = GetHashAlgorithmName(authAlgo);

    return rsa.VerifyData(message, signature, hashAlgorithmName, RSASignaturePadding.Pkcs1);
  }

  private static HashAlgorithmName GetHashAlgorithmName(string authAlgorithm)
  {
    //PayPal sends "SHA256withRSA"
    var hashAlgorithmName = authAlgorithm.Replace("withRSA", string.Empty, StringComparison.OrdinalIgnoreCase);
    return new HashAlgorithmName(hashAlgorithmName);
  }
}
Enter fullscreen mode Exit fullscreen mode

That's essentially the verification process described in pseudocode, but let me add a few remarks.

The code assumes authAlgo is formatted as <hashAlgorithm>with<signatureAlgorithm>, but only supports RSA as the signature algorithm for now. It should be straightforward to add support for DSA, ECDH, or post-quantum algorithms like ML-DSA in the future if necessary. For the hash algorithm, it extracts the first part of that string, passes it to the HashAlgorithmName constructor, and therefore supports SHA256withRSA, SHA512withRSA, or SHA3-512withRSA. Mind you, this would also allow garbage like SorryNotSorrywithRSA, but at that point I prefer more concise syntax over excessive defensiveness, since it'll throw an exception when used anyway.

You might have noticed that AppendAsync advances the stream. Since I'm only using it for HttpRequest.Body, I was okay with leaving the stream reset to the caller. To read an HTTP request body multiple times, you can buffer it in memory with HttpRequest.EnableBuffering(). Here's my wrapper to make it easier to use with HttpRequest:

using Microsoft.AspNetCore.Http;

public static class PayPalWebhookVerifierExtensions
{
  public static async Task<bool> Verify(this PayPalWebhookVerifier verifier,
                                        string webhookId,
                                        HttpRequest request,
                                        CancellationToken cancellationToken)
  {
    string authAlgo;
    string certUrl;
    string transmissionId;
    string transmissionSig;
    string transmissionTime;

    try
    {
      authAlgo = GetSingleHeaderValue(request, "PAYPAL-AUTH-ALGO");
      certUrl = GetSingleHeaderValue(request, "PAYPAL-CERT-URL");

      transmissionId = GetSingleHeaderValue(request, "PAYPAL-TRANSMISSION-ID");
      transmissionSig = GetSingleHeaderValue(request, "PAYPAL-TRANSMISSION-SIG");
      transmissionTime = GetSingleHeaderValue(request, "PAYPAL-TRANSMISSION-TIME");
    }
    catch (ArgumentException)
    {
      return false;
    }

    request.EnableBuffering();
    try
    {
      return await verifier.Verify(webhookId, authAlgo, certUrl, transmissionId, transmissionSig, transmissionTime, request.Body, cancellationToken);
    }
    finally
    {
      request.Body.Seek(0, SeekOrigin.Begin);
    }
  }

  private static string GetSingleHeaderValue(HttpRequest request, string headerName)
  {
    var headerValues = request.Headers[headerName];
    if (headerValues.Count == 0)
      throw new ArgumentException($"HTTP header {headerName} was not present.");

    if (headerValues.Count > 1)
      throw new ArgumentException($"Multiple HTTP headers for {headerName} were present.");

    return headerValues.ToString();
  }
}
Enter fullscreen mode Exit fullscreen mode

Nice.

Certificate validation and caching

You might have noticed I'm using a separate class called PayPalWebhookSignatureCertificateCache to retrieve the certificate, which I haven't shown yet. It deserves some explanation, but let me show you the code first:

using Microsoft.Extensions.Caching.Distributed;
using System.Security.Cryptography.X509Certificates;

public sealed class PayPalWebhookSignatureCertificateCache(IHttpClientFactory httpClientFactory,
                                                           IDistributedCache distributedCache)
{
  public async Task<X509Certificate2?> GetCertificateByCertUrl(Uri certUri, CancellationToken cancellationToken)
  {
    ArgumentNullException.ThrowIfNull(certUri);

    var canonicalCacheKey = certUri.ToString();
    var certData = await distributedCache.GetAsync(canonicalCacheKey, cancellationToken);

    var cacheHit = certData is not null;
    if (!cacheHit)
    {
      var httpClient = httpClientFactory.CreateClient(nameof(PayPalWebhookSignatureCertificateCache));
      certData ??= await httpClient.GetByteArrayAsync(certUri, cancellationToken);
    }

    var certificate = X509CertificateLoader.LoadCertificate(certData!);
    if (!certificate.Verify())
    {
      if (cacheHit)
        await distributedCache.RemoveAsync(canonicalCacheKey, cancellationToken);

      certificate.Dispose();
      return null;
    }

    if (!cacheHit)
      await StoreInCache(canonicalCacheKey, certificate, cancellationToken);

    return certificate;
  }

  private Task StoreInCache(string cacheKey, X509Certificate2 certificate, CancellationToken cancellationToken)
  {
    var entryOptions = new DistributedCacheEntryOptions
    {
      AbsoluteExpiration = certificate.NotAfter
    };
    return distributedCache.SetAsync(cacheKey, certificate.RawData, entryOptions, cancellationToken);
  }
}

Enter fullscreen mode Exit fullscreen mode

This class takes the URI instance constructed from the certUrl parameter and tries to return a valid X509Certificate2 from either cache or the given endpoint. "Tries?" Let me explain.

Explaining PKI goes far beyond the scope of this article, but the short version is that it depends on trust. You trust a Certificate Authority to issue certificates to legitimate resource owners, and you trust those authorities to maintain their Certificate Revocation Lists. These lists essentially tell you: While this certificate seems valid, I'm telling you it's not. You should definitely respect that and consider the certificate invalid, since revocation happens when, for example, the private key has been compromised. Luckily, X509Certificate2 offers an all-in-one verification with the Verify() method. Alternatively, you could build an X509Chain yourself by specifying the policies you want to enforce. In any case: I verify both cached certificates and freshly downloaded ones. If a certificate fails verification, I won't use it and will remove it from the cache if necessary.

Readers of Part I are already familiar with how I like to match cache entry expiration with the lifetimes of stored objects. In this case, the NotAfter property determines the expiration, since the only way the cache entry needs to be invalidated earlier is because of revocation. And again: bandwidth matters. In this case even more so, since those certificates are roughly ~2KB that you'd download on each verification attempt. It doesn't sound like much, but doing that on each and every incoming webhook request adds up - besides, this endpoint may experience outages too.

Unlike in Part I, I'm using IDistributedCache because X509Certificate2 has a property called RawData. This is the certificate content as byte[], so you don't have to deal with serialization at all and can simply use it as the cache entry value. If you want, you can configure a truly distributed cache like Redis, or just stick with an in-memory implementation that's ready for scaling.

On clues and trust issues

Earlier I intentionally introduced the term clues to indicate a certain level of trust. I broke down why a call from Lisa made me think my bank account might actually be in trouble. Now let's find those clues for a webhook request with a matching signature:

  • The caller knows the endpoint in my application that accepts webhooks. - Or they're good at guessing.
  • The caller knows the webhookId, which is the only component of the message string not transmitted with the request itself. - Or they stood beside me when I opened the PayPal developer portal and noted it.
  • Since the message was signed, they possess the private key to a valid certificate hosted within the paypal.com domain. - Or...

At this point, if it walks like a duck and quacks like a duck... A potential attacker would have to invest quite a lot of effort to send malicious requests that you'd consider legitimate. What do we have left - DNS poisoning to redirect the certificate download maybe? Hard and unlikely, but not impossible. Depends on the reward, I'd argue.

Let's get back to Lisa briefly. She told me to come over to change my card in person. Why not do the same when a webhook request arrives? Well, not fly over to San Jose and ask them in person, but rather call the API to get the current state of whatever the request content claims.

public void HandlePayPalPaymentNotification(PaymentSuccessfulNotificationDtoOrWhatever webhook) {
  var payment = payPalApi.GetPayment(webhook.PaymentId);
  //Inspect payment.State or whatever you would do with it
}
Enter fullscreen mode Exit fullscreen mode

Still not 100% secure - DNS poisoning mentioned above would obviously also affect outgoing HTTP requests - but you get additional security for the price of an API roundtrip. That's reasonable.

Also, webhook senders typically require you to complete the request within a certain timeframe to avoid blocking their resources indefinitely. PayPal enforces a maximum timeout of 30 seconds; anything exceeding that will see the request cancelled. If you're in the middle of booking the payment in your accounting system, that might lead to trouble. Instead, start a background job that handles the processing and return HTTP 200 immediately.

Integrating into ASP.NET Core

The depth of integration depends on your requirements. Nothing stops you from injecting PayPalWebhookVerifier into a controller action or minimal API endpoint and invoking the verification manually:

app.MapPost("/payPal/notify", async (HttpRequest request, PayPalWebhookVerifier verifier, IOptions<YourPayPalWebhookOptions> options, CancellationToken cancellationToken) => {
  var couldVerify = await verifier.Verify(options.Value.WebhookId, request, cancellationToken);
  if (!couldVerify)
    return TypedResults.Unauthorized();

  //...
});
Enter fullscreen mode Exit fullscreen mode

But as the status code 401 for negative results implies, what we're essentially doing by running the verification procedure is authenticating the request. Since ASP.NET Core provides a whole subsystem with various integration points for authentication, why not utilize it and provide a reusable component?

To implement a custom authentication scheme, you need to bind an implementation of IAuthenticationHandler to a scheme name. To avoid starting from scratch, you can instead implement AuthenticationHandler<T>.

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

public sealed class PayPalWebhookAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
                                                       ILoggerFactory logger,
                                                       UrlEncoder encoder,
                                                       PayPalWebhookVerifier payPalWebhookVerifier)
  : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
  protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
  {
    var endpoint = Context.GetEndpoint();
    if (endpoint is null)
      return AuthenticateResult.NoResult();

    var webhookMetadata = endpoint.Metadata.GetRequiredMetadata<PayPalWebhookMetadata>();
    var webhookId = webhookMetadata.WebhookIdAccessor(Context.RequestServices);

    var couldVerify = await payPalWebhookVerifier.Verify(webhookId, Context.Request, Context.RequestAborted);
    if (!couldVerify)
      return AuthenticateResult.Fail("Could not verify PayPal Webhook Request.");

    var ticket = BuildAuthenticationTicket(Scheme.Name);
    return AuthenticateResult.Success(ticket);
  }

  private static AuthenticationTicket BuildAuthenticationTicket(string authenticationSchemeName)
  {
    IEnumerable<Claim> claims = [new Claim(ClaimsIdentity.DefaultNameClaimType, "PayPal Webhook")];
    var identity = new ClaimsIdentity(claims, authenticationSchemeName);
    var principal = new ClaimsPrincipal(identity);
    return new AuthenticationTicket(principal, authenticationSchemeName);
  }
}

internal sealed record PayPalWebhookMetadata(Func<IServiceProvider, string> WebhookIdAccessor);
Enter fullscreen mode Exit fullscreen mode

PayPalWebhookMetadata provides a way to access the Webhook ID. Normally this would be encapsulated in a dedicated class derived from AuthenticationSchemeOptions. I decided against that because there's usually a one-to-one mapping between a webhook registration and an application endpoint, meaning the Webhook ID is a property that's only valid for one specific endpoint. Therefore, it felt natural to implement it as endpoint metadata instead. Since the accessor's parameter is the request-scoped service provider, you can obtain the Webhook ID however you like.

What's left now is to provide an extension to register the required services and authentication scheme, and an extension to configure an endpoint to authenticate with this specific authentication scheme.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System.Diagnostics.CodeAnalysis;

public static class PayPalWebhookVerificationExtensions
{
  public const string AuthenticationSchemeName = "PayPalWebhook";

  public static IServiceCollection AddPayPalWebhookVerification(this IServiceCollection services)
  {
    ArgumentNullException.ThrowIfNull(services);

    services.AddHttpClient();
    services.AddDistributedMemoryCache();
    services.TryAddSingleton<PayPalWebhookVerifier>();
    services.TryAddSingleton<PayPalWebhookSignatureCertificateCache>();

    return services.AddAuthenticationCore(options =>
    {
      if (!options.Schemes.Any(s => s.Name == AuthenticationSchemeName))
        options.AddScheme<PayPalWebhookAuthenticationHandler>(AuthenticationSchemeName, "PayPal Webhook");
    });
  }

  public static RouteHandlerBuilder MapPayPalWebhook<TBuilder>(this TBuilder builder,
                                                               [StringSyntax("Route")] string pattern,
                                                               Delegate handler,
                                                               Func<IServiceProvider, string> webhookIdAccessor)
    where TBuilder : IEndpointRouteBuilder
  {
    ArgumentNullException.ThrowIfNull(builder);
    ArgumentNullException.ThrowIfNull(webhookIdAccessor);

    var endpoint = builder.MapPost(pattern, handler);

    endpoint.WithMetadata(new PayPalWebhookMetadata(webhookIdAccessor));
    endpoint.RequireAuthorization(b =>
    {
      b.AuthenticationSchemes = [AuthenticationSchemeName];
      b.RequireAuthenticatedUser();
    });

    return endpoint;
  }
}
Enter fullscreen mode Exit fullscreen mode

By calling MapPayPalWebhook, an HTTP POST endpoint is added with webhookIdAccessor as endpoint metadata, and a custom authorization policy ensures the request will be authenticated using the scheme registered above.

Implementing verification as an authentication scheme also has the nice side effect that you can bypass it in development environments:

var webhookEndpoint = app.MapPayPalWebhook("/payPal/notify", ...);
if (app.Environment.IsDevelopment())
  webhookEndpoint.AllowAnonymous();
Enter fullscreen mode Exit fullscreen mode

To test the endpoint, you don't have to actually build signed requests or use the webhooks simulator - simply use Swagger or Scalar to paste in the JSON payload.

Let's revisit the example from earlier in this section:

services.AddPayPalWebhookVerification();

//...

app.MapPayPalWebhook("/payPal/notify", () => {
  //...
}, s => s.GetRequiredService<IOptions<YourPayPalWebhookOptions>>().Value.WebhookId);
Enter fullscreen mode Exit fullscreen mode

Now that's what I'd call separation of concerns.

Closing words

You made it to the end - congratulations. When I started writing, I didn't expect to have this much to say, but apparently there was a lot to explain about properly verifying PayPal webhooks using C# and .NET 10. If you have feedback or suggestions for improvements, I'd truly appreciate a comment. As in Part I, I don't claim to have the best solution possible, but what I presented worked well for me.

If my code or explanations saved you time in a business context, consider leaving a donation.

Buy Me A Coffee

Top comments (0)