---
title: "How to Handle JWT Access Token Refresh in a .NET Application Using HttpClient"  
description: "Learn how to handle 15-minute JWT token expiration in .NET HttpClient using refresh tokens, DelegatingHandler, concurrency control, 401 retry, and secure token"  
author: "Manish Kumar"  
published: 2026-08-19  
updated: 2026-08-20  
canonical: https://answers.mindstick.com/blog/561/how-to-handle-jwt-access-token-refresh-in-a-dot-net-application-using-httpclient  
category: "programming"  
tags: [".net programming", "ASP.NET Core", "httpclient", "API Performance"]  
reading_time: 12 minutes  

---

# How to Handle JWT Access Token Refresh in a .NET Application Using HttpClient

When a .NET application integrates with an external API secured by **JWT authentication**, token management becomes an important part of the application's architecture.

A common production scenario looks like this:

1. The external API authenticates requests using a JWT access token.
2. The access token is valid for only **15 minutes**.
3. Before or after expiry, the API provides a **refresh token**.
4. Once the access token expires, continuing to use the old token results in a **401 Unauthorized** response.
5. The application must obtain a new access token and use it for subsequent API calls.

If this token lifecycle isn't handled correctly, an application can work perfectly during testing but start returning intermittent **401 errors in production**.

This article explains how to design a reliable JWT token-refresh mechanism in a .NET application using `HttpClient`.

## The Problem

Imagine your application makes requests like this:

```plaintext
GET /api/orders
Authorization: Bearer <access_token>
```

The access token is valid for 15 minutes.

For the first 15 minutes, everything works:

```plaintext
Application
    |
    |---- Bearer Token A ----> External API
    |<--------- 200 OK --------|
```

After 15 minutes, Token A expires:

```plaintext
Application
    |
    |---- Bearer Token A ----> External API
    |<-------- 401 ------------|
```

If the application continues using the same token, every request will fail.

The solution is **not to manually change the token every 15 minutes throughout the application**. Instead, token management should be centralized.

![How to Handle JWT Access Token Refresh in a .NET Application Using HttpClient](https://answers.mindstick.com/blogs/5e13133e-c65b-4890-b50c-10605fe74203/images/58f60aa2-50bf-46b0-81aa-58995b2d675e.png)

## Recommended Architecture

A clean architecture separates token management from business API calls.

```plaintext
                    ┌──────────────────────┐
                    │   Your .NET App      │
                    └──────────┬───────────┘
                               │
                         API Request
                               │
                               ▼
                    ┌──────────────────────┐
                    │   Token Manager      │
                    │                      │
                    │ Is token valid?      │
                    │ Refresh if required  │
                    └──────────┬───────────┘
                               │
                     Valid Access Token
                               │
                               ▼
                    ┌──────────────────────┐
                    │     HttpClient       │
                    └──────────┬───────────┘
                               │
                               ▼
                    ┌──────────────────────┐
                    │    External API      │
                    └──────────────────────┘
```

Your business code should not need to know whether the token is about to expire.

Instead of doing this everywhere:

```cs
var token = await GetToken();

client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", token);

var response = await client.GetAsync("/api/orders");
```

you want something closer to:

```cs
var response = await apiClient.GetOrdersAsync();
```

The API client and token service handle authentication internally.

## 1. Create a Token Response Model

First, create a model representing the authentication response returned by the external API.

```cs
public sealed class TokenResponse
{
    public string AccessToken { get; set; } = string.Empty;

    public string RefreshToken { get; set; } = string.Empty;

    public int ExpiresIn { get; set; }
}
```

Depending on the external API, the JSON may look something like:

```plaintext
{
  "access_token": "eyJhbGciOi...",
  "refresh_token": "def50200...",
  "expires_in": 900
}
```

If the API uses snake_case JSON properties, configure your serializer or map the properties accordingly.

## 2. Create a Token Manager

The token manager should be responsible for:

1. Obtaining the initial token.
2. Keeping the current access token.
3. Tracking its expiration.
4. Refreshing the token before expiration.
5. Updating the refresh token if the API rotates it.

For example:

```cs
public interface ITokenService
{
    Task<string> GetAccessTokenAsync(
        CancellationToken cancellationToken = default);
}
```

The implementation can maintain the current token.

```cs
public sealed class TokenService : ITokenService
{
    private readonly SemaphoreSlim _lock = new(1, 1);

    private string? _accessToken;
    private string? _refreshToken;
    private DateTimeOffset _expiresAt;

    public async Task<string> GetAccessTokenAsync(
        CancellationToken cancellationToken = default)
    {
        if (TokenIsValid())
        {
            return _accessToken!;
        }

        await _lock.WaitAsync(cancellationToken);

        try
        {
            // Another request may have refreshed the token
            // while this request was waiting for the lock.
            if (TokenIsValid())
            {
                return _accessToken!;
            }

            await RefreshTokenAsync(cancellationToken);

            return _accessToken!;
        }
        finally
        {
            _lock.Release();
        }
    }

    private bool TokenIsValid()
    {
        return !string.IsNullOrEmpty(_accessToken)
               && DateTimeOffset.UtcNow < _expiresAt;
    }

    private async Task RefreshTokenAsync(
        CancellationToken cancellationToken)
    {
        // Call external authentication endpoint here.
    }
}
```

The `SemaphoreSlim` is important.

Without it, imagine that 100 requests arrive at exactly the same time when the token expires.

You don't want this:

```plaintext
Request 1 ──> Refresh token
Request 2 ──> Refresh token
Request 3 ──> Refresh token
Request 4 ──> Refresh token
...
Request 100 -> Refresh token
```

Instead, you want:

```plaintext
Request 1 ──┐
Request 2 ──┤
Request 3 ──┤
Request 4 ──┤──> One token refresh
Request 5 ──┤
Request 6 ──┘
                    |
                    ▼
              New access token
```

This pattern prevents a **token refresh stampede**.

## 3. Don't Wait Until the Exact Expiration Time

A common mistake is to refresh the token exactly after 15 minutes.

For example:

```plaintext
DateTimeOffset.UtcNow >= _expiresAt
```

This can create race conditions.

Suppose the token expires at:

```plaintext
10:15:00
```

A request starts at:

```plaintext
10:14:59.900
```

By the time the request reaches the external API, the token could already be expired.

A better approach is to refresh slightly before expiration.

For example:

```cs
private bool TokenIsValid()
{
    return !string.IsNullOrEmpty(_accessToken)
           && DateTimeOffset.UtcNow.AddSeconds(60) < _expiresAt;
}
```

Now the application refreshes the token approximately **one minute before expiration**.

This is called a **refresh buffer** or **safety window**.

The exact buffer should depend on the external API and network latency. For a 15-minute token, something like 30–60 seconds is often a reasonable starting point.

## 4. Implement the Refresh Request

The exact implementation depends on the external API.

A typical OAuth-style refresh request looks like:

```cs
private async Task RefreshTokenAsync(
    CancellationToken cancellationToken)
{
    using var request = new HttpRequestMessage(
        HttpMethod.Post,
        "/oauth/token");

    request.Content = new FormUrlEncodedContent(
        new Dictionary<string, string>
        {
            ["grant_type"] = "refresh_token",
            ["refresh_token"] = _refreshToken!
        });

    using var response = await _httpClient.SendAsync(
        request,
        cancellationToken);

    response.EnsureSuccessStatusCode();

    var tokenResponse =
        await response.Content.ReadFromJsonAsync<TokenResponse>(
            cancellationToken: cancellationToken);

    if (tokenResponse is null)
    {
        throw new InvalidOperationException(
            "Token endpoint returned an empty response.");
    }

    _accessToken = tokenResponse.AccessToken;

    // Important: some APIs rotate the refresh token.
    if (!string.IsNullOrWhiteSpace(tokenResponse.RefreshToken))
    {
        _refreshToken = tokenResponse.RefreshToken;
    }

    _expiresAt = DateTimeOffset.UtcNow
        .AddSeconds(tokenResponse.ExpiresIn);
}
```

One important detail is often overlooked:

> **Always check whether the API rotates the refresh token.**

Some authentication systems return a new refresh token every time the old one is used.

If you ignore the new refresh token and continue using the old one, your application may eventually lose the ability to refresh.

## 5. Use a DelegatingHandler

A very clean solution in .NET is to use an `HttpMessageHandler`.

Instead of manually adding the JWT token to every request, create a custom `DelegatingHandler`.

```cs
public sealed class JwtAuthenticationHandler
    : DelegatingHandler
{
    private readonly ITokenService _tokenService;

    public JwtAuthenticationHandler(
        ITokenService tokenService)
    {
        _tokenService = tokenService;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var accessToken =
            await _tokenService.GetAccessTokenAsync(
                cancellationToken);

        request.Headers.Authorization =
            new AuthenticationHeaderValue(
                "Bearer",
                accessToken);

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

Now authentication becomes transparent to your API client.

## 6. Register HttpClient Correctly

In an ASP.NET Core application, you can register the services with dependency injection.

```cs
builder.Services.AddSingleton<ITokenService, TokenService>();

builder.Services.AddTransient<JwtAuthenticationHandler>();

builder.Services.AddHttpClient<IExternalApiClient, ExternalApiClient>(
    client =>
    {
        client.BaseAddress =
            new Uri("https://api.example.com");
    })
    .AddHttpMessageHandler<JwtAuthenticationHandler>();
```

Your external API client can then remain simple:

```cs
public sealed class ExternalApiClient
    : IExternalApiClient
{
    private readonly HttpClient _httpClient;

    public ExternalApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<Order[]> GetOrdersAsync(
        CancellationToken cancellationToken = default)
    {
        return await _httpClient.GetFromJsonAsync<Order[]>(
            "/api/orders",
            cancellationToken)
            ?? [];
    }
}
```

Notice what is missing.

There is no:

```plaintext
Authorization
```

header management inside the business code.

The `JwtAuthenticationHandler` handles it automatically.

## 7. What About a 401 Response?

Refreshing proactively is good, but production systems should still handle an unexpected `401`.

For example:

```plaintext
Application
     |
     | Token A
     ▼
External API
     |
     | 401 Unauthorized
     ▼
Authentication Handler
     |
     | Refresh Token
     ▼
Token B
     |
     | Retry request
     ▼
External API
     |
     | 200 OK
```

However, there is an important warning.

**Don't blindly retry every 401 indefinitely.**

A safer pattern is:

- Send request with current token.
- If the API returns 401, refresh the token.
- Retry the request **once**.
- If the second request also returns 401, fail the operation.
- This prevents infinite retry loops.

You can implement this using another handler or a resilience library such as Microsoft's current resilience tooling for `HttpClient`.

## 8. Be Careful With Request Bodies

There is another production issue that is easy to miss.

Suppose you send:

```plaintext
POST /api/orders
```

with a request body.

If the first request receives a 401 and you want to retry it, you need to ensure the request content can be sent again.

A request isn't always safely reusable after it has already been sent.

For JSON requests, it is often better to create a new `HttpRequestMessage` for the retry rather than trying to reuse the original request blindly.

This becomes particularly important for:

- POST requests
- PUT requests
- PATCH requests
- Streaming request bodies
- Large uploads

## 9. Don't Store the Token in Static Variables Without Thinking About Scope

A simple implementation might use:

```plaintext
private static string _accessToken;
```

This can work in a single-instance application under certain conditions, but it creates problems as the application grows.

You need to understand your deployment model.

For example:

```plaintext
                 Load Balancer
                /      |      \
               /       |       \
              ▼        ▼        ▼
           Server 1  Server 2  Server 3
```

If every server maintains its own token state:

```plaintext
Server 1 → Token A
Server 2 → Token B
Server 3 → Token C
```

you may end up refreshing tokens independently.

Whether this is acceptable depends entirely on how the external API manages refresh tokens.

If the provider allows only one active refresh token, multiple application instances can create problems.

## 10. Distributed Applications Need Distributed Token Storage

If your application runs multiple instances, consider storing token state in a distributed cache such as Redis.

Conceptually:

```plaintext
             ┌─────────────────┐
             │    Redis        │
             │                 │
             │ Access Token    │
             │ Refresh Token   │
             │ Expiration      │
             └────────┬────────┘
                      │
          ┌───────────┼───────────┐
          ▼           ▼           ▼
      App Server   App Server   App Server
          1            2            3
```

However, simply putting the token in Redis isn't enough.

You also need to think about **distributed locking** so that multiple application instances don't refresh the same token simultaneously.

For a multi-instance production system, the architecture should therefore include:

- Distributed token storage
- Distributed locking
- Token expiration tracking
- Refresh-token rotation
- Failure handling

## 11. Never Log JWT Tokens

This is one of the most important production rules.

Avoid logging:

```plaintext
_logger.LogInformation(
    "Access token: {Token}",
    accessToken);
```

Don't put access tokens or refresh tokens into:

- Application logs
- Exception messages
- Telemetry
- URLs
- Query strings
- Git repositories
- Source code
- Client-side JavaScript

Instead, log safe metadata:

```plaintext
Token refresh started
Token refresh succeeded
Token expires at 10:15 UTC
External API returned 401
Token refresh failed
```

This gives you observability without exposing credentials.

## 12. What If Refresh Fails?

A production token manager should distinguish between different failures.

For example:

```plaintext
401 from API
      |
      ▼
Try token refresh
      |
      ├── Success → Retry request once
      |
      └── Failure
             |
             ├── Refresh token expired
             │       ↓
             │   Re-authenticate
             │
             └── Temporary server/network error
                     ↓
                  Retry carefully
```

- If the refresh token itself is expired or revoked, repeatedly retrying it won't help.
- The application may need to perform a complete authentication flow again.

## 13. Avoid Refreshing on Every API Call

You don't want this:

```plaintext
Request 1 → Refresh → API
Request 2 → Refresh → API
Request 3 → Refresh → API
Request 4 → Refresh → API
```

That defeats the purpose of the refresh token.

Instead:

```plaintext
Request 1 ──┐
Request 2 ──┤
Request 3 ──┤──> Existing valid token
Request 4 ──┘
```

Then, close to expiration:

```plaintext
Request
   |
   ▼
Token nearly expired
   |
   ▼
Refresh once
   |
   ▼
Store new token
   |
   ▼
Continue using new token
```

This is why the combination of **expiration tracking + locking** is so important.

## 14. A Practical Production Design

For most .NET applications, I'd structure the integration something like this:

```plaintext
ExternalApiClient
       |
       ▼
JwtAuthenticationHandler
       |
       ▼
TokenService
       |
       ├── Access Token
       ├── Refresh Token
       ├── Expiration
       └── Refresh Lock
       |
       ▼
HttpClient
       |
       ▼
External API
```

For a single application instance:

```plaintext
TokenService
     |
     └── SemaphoreSlim
```

For multiple application instances:

```plaintext
TokenService
     |
     ├── Redis
     │    └── Token state
     │
     └── Distributed Lock
```

## 15. The Key Principle

The biggest architectural mistake is treating token refresh as a responsibility of every API call.

Don't do this:

```cs
// Business code
var token = await RefreshTokenIfNecessary();

client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", token);

await client.GetAsync(...);
```

Instead, centralize authentication:

```plaintext
Business Code
      |
      ▼
ExternalApiClient
      |
      ▼
Authentication Handler
      |
      ▼
Token Service
      |
      ▼
HttpClient
      |
      ▼
External API
```

Your application code then doesn't care whether the token has 14 minutes left or 5 seconds left.

The token service handles that complexity.

## Final Takeaway

If your external API issues a JWT that expires every 15 minutes, **don't build a timer that blindly changes the token every 15 minutes**.

A more reliable production approach is:

- Store the access token and refresh token securely.
- Track the access-token expiration time.
- Refresh the token slightly before expiration.
- Use a `SemaphoreSlim` to prevent concurrent refreshes.
- Update the refresh token if the provider rotates it.
- Use `DelegatingHandler` to automatically attach the JWT to outgoing `HttpClient` requests.
- Handle unexpected `401` responses with a **single refresh-and-retry**.
- For multiple application instances, use distributed storage and locking.
- Never log access or refresh tokens.
- Handle refresh-token expiration separately from temporary network failures.

With this design, the 15-minute token lifetime becomes an implementation detail rather than something every part of your application needs to worry about.

---

Original Source: https://answers.mindstick.com/blog/561/how-to-handle-jwt-access-token-refresh-in-a-dot-net-application-using-httpclient

Copyright © MindStick Software Pvt. Ltd. This Markdown version is provided for developers, AI systems, and offline reading.
