DEV Community

Cover image for Handling PayPal API Authentication the .NET Way
Peter Wurzinger
Peter Wurzinger

Posted on

Handling PayPal API Authentication the .NET Way

This is the first post in a two-part series where I share my insights on the PayPal API and C#/.NET:

  • Part I covers proper HTTP request authentication for the PayPal API
  • Part II will demonstrate 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 setting up a PayPal developer account — check the official docs for that. If you're still reading, you probably already know the drill.

This also isn't about architecting payment or subscription systems. It's purely focused on handling API authentication so you can concentrate on the more interesting bits.

I'm using C# 14 and .NET 10, but you can adapt this code to earlier versions with minimal changes and some additional NuGet packages. Unless you're stuck on .NET Framework 3.5 — then you need an exorcist.

Store your secrets the .NET way

After creating an application in the PayPal developer portal, you'll receive a CLIENT_ID and CLIENT_SECRET. As the name suggests, one is more secret than the other. I recommend using the options pattern to bind and retrieve them at runtime.

Beyond the credentials, I also store:

  • The PayPal API base address
  • An expiration threshold timespan

The base address lets you switch between Sandbox and Live environments (or even route through a proxy if you're feeling adventurous). The expiration threshold provides a safety buffer to account for clock skew or race conditions between API calls and token expiration.

public sealed record PayPalOptions
{
  public required Uri BaseAddress { get; init; }
  public required string ClientId { get; init; }
  public required string ClientSecret { get; init; }
  public TimeSpan TokenRefreshThreshold { get; init; } = TimeSpan.FromMinutes(5);
}
Enter fullscreen mode Exit fullscreen mode

I like to validate my options — either via Data Annotations or by implementing IValidateOptions:

public sealed class PayPalOptionsValidation : IValidateOptions<PayPalOptions>
{
  public ValidateOptionsResult Validate(string? name, PayPalOptions options)
  {
    var resultBuilder = new ValidateOptionsResultBuilder();

    if (options.BaseAddress is null)
      resultBuilder.AddError($"{nameof(PayPalOptions.BaseAddress)} must not be null.", nameof(PayPalOptions.BaseAddress));

    if (string.IsNullOrEmpty(options.ClientId))
      resultBuilder.AddError($"{nameof(PayPalOptions.ClientId)} must not be null or empty.", nameof(PayPalOptions.ClientId));

    if (string.IsNullOrEmpty(options.ClientSecret))
      resultBuilder.AddError($"{nameof(PayPalOptions.ClientSecret)} must not be null or empty.", nameof(PayPalOptions.ClientSecret));

    if (options.TokenRefreshThreshold < TimeSpan.Zero)
      resultBuilder.AddError($"{nameof(PayPalOptions.TokenRefreshThreshold)} must not be less than 0.", nameof(PayPalOptions.TokenRefreshThreshold)); ;

    return resultBuilder.Build();
  }
}
Enter fullscreen mode Exit fullscreen mode

In development, you could bind the values from User Secrets:

{
    "PayPal": {
        "Endpoint": "https://api-m.paypal.com/",
        "ClientId": "...",
        "ClientSecret": "..."
    }
}
Enter fullscreen mode Exit fullscreen mode

and bind it via:

services.Configure<PayPalOptions>(configuration.GetSection("PayPal"));
Enter fullscreen mode Exit fullscreen mode

BUT PLEASE: Don't dump secrets into appsettings.Development.json. Use User Secrets — there's no excuse not to.

Tokens everywhere

The docs specify that you need to attach an OAuth 2.0 access token as an Authorization header to access most API endpoints. This access token is neither your CLIENT_ID nor your CLIENT_SECRET. Instead, there's a dedicated endpoint at v1/oauth2/token that exchanges your credentials for a short-lived token.

Obtain your token

Conceptually, before sending a request to the PayPal API, you need to:

  • Retrieve an instance of PayPalOptions
  • Exchange your credentials for an access token by calling the OAuth token endpoint
  • Attach the returned access token as a header to your request

For dynamically retrieving credentials and attaching them to HTTP requests, I find implementing a DelegatingHandler most convenient. It provides fine-grained control over the send pipeline and integrates seamlessly with IHttpClientFactory. If you're not already familiar with IHttpClientFactory concepts, get up to speed — it'll make your life easier.

Here's an implementation that does exactly that:

using Microsoft.Extensions.Options;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Serialization;

namespace Yeebra.Infrastructure.PayPalAuthentication;

public sealed class PayPalAuthenticationHandler(HttpClient authClient,
                                                IOptionsMonitor<PayPalOptions> optionsMonitor) : DelegatingHandler
{
  protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
  {
    var options = optionsMonitor.CurrentValue;

    var authenticationToken = await RenewToken(options, cancellationToken);
    request.Headers.Authorization = new("Bearer", authenticationToken!.AccessToken);

    return await base.SendAsync(request, cancellationToken);
  }

  private async Task<PayPalTokenResponse> RenewToken(PayPalOptions options, CancellationToken cancellationToken)
  {
    var tokenEndpoint = new Uri("v1/oauth2/token", UriKind.Relative);

    using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint);

    using var content = new FormUrlEncodedContent([new("grant_type", "client_credentials")]);
    request.Content = content;
    request.Headers.Authorization = new("Basic", GetBasicAuthCredentials());

    var response = await authClient.SendAsync(request, cancellationToken);
    response.EnsureSuccessStatusCode();

    return (await response.Content.ReadFromJsonAsync<PayPalTokenResponse>(cancellationToken: cancellationToken))!;

    string GetBasicAuthCredentials()
    {
      var authLiteral = $"{options.ClientId}:{options.ClientSecret}";
      var encodedLiteral = Encoding.UTF8.GetBytes(authLiteral);

      return Convert.ToBase64String(encodedLiteral);
    }
  }

  private sealed record PayPalTokenResponse
  {
    [JsonPropertyName("scope")]
    public required string Scope { get; init; }

    [JsonPropertyName("access_token")]
    public required string AccessToken { get; init; }

    [JsonPropertyName("token_type")]
    public required string TokenType { get; init; }

    [JsonPropertyName("app_id")]
    public required string AppId { get; init; }

    [JsonPropertyName("expires_in")]
    public required int ExpiresIn { get; init; }

    [JsonPropertyName("nonce")]
    public required string Nonce { get; init; }
  }
}
Enter fullscreen mode Exit fullscreen mode

Since PayPalAuthenticationHandler depends on HttpClient, you need to configure it during registration. Providing an extension method on IServiceCollection is a well-established pattern.

For my use cases, accepting the dependent service as a generic type parameter works well — similar to AddHttpClient<TClient>(). The HttpClient dependency in your client type will automatically be configured to retrieve an access token before each request. The name AddPayPalHttpClient telegraphs its purpose, but add docs if needed.

Depending on your architecture, you might need additional overloads, but this should give you the general idea:

public static class PayPalHttpClientExtensions
{
  public static IServiceCollection AddPayPalHttpClient<TClient>(this IServiceCollection services)
      where TClient : class
  {
    services.AddOptionsWithValidateOnStart<PayPalOptions, PayPalOptionsValidation>();
    services.AddHttpClient<PayPalAuthenticationHandler>(ConfigureBaseAddress);

    services.AddHttpClient<TClient>(ConfigureBaseAddress)
            .AddHttpMessageHandler<PayPalAuthenticationHandler>();

    return services;
  }

  private static void ConfigureBaseAddress(IServiceProvider serviceProvider, HttpClient httpClient)
  {
    var payPalOptions = serviceProvider.GetRequiredService<IOptions<PayPalOptions>>();
    httpClient.BaseAddress = payPalOptions.Value.BaseAddress;
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that both the client service and PayPalAuthenticationHandler are configured with ConfigureBaseAddress.

A PayPal API client and its registration could look like this:

class PayPalPaymentClient(HttpClient httpClient) {
  //Do some fancy financial calls to e.g. /v2/payments/...
}

//Registration
services.AddPayPalHttpClient<PayPalPaymentClient>();
Enter fullscreen mode Exit fullscreen mode

Cache your token

The implementation above works fine but requests a new token for every API call, even when the previous one is still valid. This might be acceptable if calls are infrequent.

But let's be real: bandwidth matters. In cloud environments, it costs actual money (or egress units or cloud-funny-money). Caching the token until expiration is straightforward — let me show you.

.NET offers several caching options:

Which suits you best depends on your use case and deployment topology, but it likely only matters if you already know the driving factors. I recommend keeping it simple initially — use an in-memory cache to handle token expiration within the application lifetime and go fancy when you see the need.

Conceptually, check if there's an unexpired cache hit for the token response:

  • If yes, use the cached token
  • If not, retrieve and cache it

The cache entry expiration equals the token expiration minus the expiration threshold (to account for clock skew, network delays, and thunderstorms).

I added dependencies on TimeProvider and IMemoryCache, and implemented token renewal as a GetOrCreate factory:

public sealed class PayPalAuthenticationHandler(HttpClient authClient,
                                                TimeProvider timeProvider,
                                                IMemoryCache cache,
                                                IOptionsMonitor<PayPalOptions> optionsMonitor) : DelegatingHandler
{
  protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
  {
    var options = optionsMonitor.CurrentValue;

    var cacheKey = $"PayPal:{options.ClientId}:Token";
    var authenticationToken = await cache.GetOrCreateAsync(cacheKey, async entry =>
    {
      var renewalTime = timeProvider.GetUtcNow();
      var token = await RenewToken(options, cancellationToken);

      entry.AbsoluteExpiration = renewalTime + TimeSpan.FromSeconds(token.ExpiresIn) - options.TokenRefreshThreshold;

      return token;
    });

    request.Headers.Authorization = new("Bearer", authenticationToken!.AccessToken);

    return await base.SendAsync(request, cancellationToken);
  }

  //Rest stays the same
Enter fullscreen mode Exit fullscreen mode

The token contains an ExpiresIn property (expiration time in seconds). You could feed it directly into entry.AbsoluteExpirationRelativeToNow and it would probably work fine. But that felt sloppy to me — the value for "now" would differ from the "now" used by PayPal to determine ExpiresIn. So I took a defensive approach and captured the current time before the request as the base for adding ExpiresIn.

The beauty of using a cache: you don't have to manage token expiration manually. The cache discards expired tokens and calls the factory method to renew them automatically.

And that's it. Tokens are cached for the lifetime of the application service container, which for web apps means until the app instance shuts down.

Oh and you need to register services needed for IMemoryCache:

//PayPalHttpClientExtensions
services.AddMemoryCache();
Enter fullscreen mode Exit fullscreen mode

Cache your token even harder

You could. If you're feeling ambitious, use IDistributedCache instead of IMemoryCache. There's no GetOrCreate helper and you'll need to serialize your token, but if that's worthwhile for you, replace the cache logic with something like:

PayPalTokenResponse? authenticationToken;
var serializedToken = await distributedCache.GetAsync(cacheKey, cancellationToken);
if (serializedToken is null)
{
  var renewalTime = timeProvider.GetUtcNow();

  authenticationToken = await RenewToken(options, cancellationToken);

  serializedToken = JsonSerializer.SerializeToUtf8Bytes(authenticationToken);
  await distributedCache.SetAsync(cacheKey, serializedToken, new()
  {
    AbsoluteExpiration = renewalTime + TimeSpan.FromSeconds(authenticationToken.ExpiresIn) - options.TokenRefreshThreshold
  }, cancellationToken);
}
else
{
  authenticationToken = JsonSerializer.Deserialize<PayPalTokenResponse>(serializedToken);
}
Enter fullscreen mode Exit fullscreen mode

Since PayPalTokenResponse already has JSON serialization annotations, using it as the serialization format is a nice synergy.

Caching Level over 9000

I tried. HybridCache seemed like the logical next step to offer an opt-in mechanism for configuring an additional distributed cache like Redis or memcached.
Unfortunately, HybridCache doesn't appear to support setting cache entry expiration dynamically. At this point, for my use case, it simply wasn't worth digging deeper to force it to work.

Things I didn't care about

Exposing the Token

There was no need to, so I didn't. I'm fine with storing and renewing it in the HTTP message handler without external access. If you need the token elsewhere, consider implementing a dedicated PayPalTokenService that encapsulates token handling.

Transient failures and dedicated exceptions

Yeah, guilty as charged. For the token exchange API call, you could hook up a resilience pipeline to handle timeouts and HTTP 500 errors. Instead of EnsureSuccessStatusCode(), you could inspect the status and throw something like a PayPalAuthenticationException for HTTP 4xx.

Closing words

In this article, we explored how to encapsulate authentication for PayPal API requests and demonstrated ways to reduce your application's traffic by caching tokens.

I don't claim this is the best solution available — I wanted to share what worked well for me. If you have feedback or suggestions, drop them in the comments.

If this code or explanation saved you time in a business context, consider leaving a donation.

Buy Me A Coffee

Top comments (0)