GitHub integrations often need to operate for long periods without requiring users to authenticate repeatedly. OAuth-based applications can support this scenario through expiring access tokens and refresh tokens.

However, long-lived authentication introduces an important engineering challenge: the application must safely refresh credentials without exposing secrets, interrupting requests, or creating multiple competing refresh operations.

This becomes especially important for enterprise applications where an expired token can affect scheduled jobs, background services, APIs, and user-facing workflows.

This article explains how to design a safer OAuth token-refresh workflow in .NET, how to handle concurrent refresh requests, and how to recover when refresh operations fail.

Understanding OAuth Token Refresh

A simplified OAuth flow looks like this:

User
 |
 v
Authorization
 |
 v
Authorization Code
 |
 v
Access Token + Refresh Token
 |
 v
Application

When an access token expires:

Application
    |
    v
Access Token Expired
    |
    v
Refresh Token
    |
    v
New Access Token

The refresh token allows the application to obtain a new access token without requiring the user to complete the entire authorization flow again.

The exact token lifetime, expiration behavior, and refresh-token policies depend on the OAuth application and GitHub's current authentication behavior.

Why Token Refresh Is Difficult

A basic implementation might simply detect a 401 Unauthorized response and request a new token.

That sounds straightforward, but production applications have additional concerns:

  • Multiple requests may discover expiration simultaneously.

  • Refresh tokens are sensitive credentials.

  • Refresh operations may fail.

  • Network failures can occur.

  • Tokens may rotate.

  • Multiple application instances may attempt refresh concurrently.

  • Existing requests may already be using an older token.

Consider:

Request A ──┐
Request B ──┼──> Access Token Expired
Request C ──┘
             |
             v
        Refresh Token

Without synchronization, all three requests might attempt a refresh.

That can produce unnecessary requests and race conditions.

Store Tokens Securely

Never hard-code OAuth credentials:

var refreshToken =
    "hard-coded-secret";

Do not store tokens in:

  • Source code

  • Git repositories

  • Plain-text configuration committed to source control

  • Application logs

  • Exception messages

  • URLs

  • Client-side storage unless the architecture explicitly requires it and appropriate protections are in place

Use a secure secret-management mechanism appropriate for the deployment environment.

The application should retrieve secrets only when required.

Model the Token State

A token record can represent the current authentication state:

public sealed record OAuthToken(
    string AccessToken,
    string RefreshToken,
    DateTimeOffset ExpiresAt);

A helper method can determine whether the token is close to expiration:

public static bool IsExpiringSoon(
    OAuthToken token,
    TimeSpan buffer)
{
    return token.ExpiresAt <=
        DateTimeOffset.UtcNow.Add(buffer);
}

The buffer is important.

Waiting until the exact expiration time can create unnecessary failures when a request begins just before expiration and reaches GitHub after the token has expired.

For example:

if (IsExpiringSoon(token, TimeSpan.FromMinutes(2)))
{
    // Refresh before starting another API operation.
}

The exact buffer should be selected according to the application's request duration and operational characteristics.

Creating a Token Provider

Keep token management separate from GitHub API logic.

public interface ITokenProvider
{
    Task<string> GetAccessTokenAsync(
        CancellationToken cancellationToken);
}

The GitHub client then depends on the abstraction:

public sealed class GitHubClient
{
    private readonly ITokenProvider tokenProvider;

    public GitHubClient(
        ITokenProvider tokenProvider)
    {
        this.tokenProvider = tokenProvider;
    }

    public async Task<string> GetUserAsync(
        CancellationToken cancellationToken)
    {
        var token =
            await tokenProvider.GetAccessTokenAsync(
                cancellationToken);

        // Use the token for the API request.

        return token;
    }
}

This keeps authentication concerns out of business logic.

Refreshing the Token

A token service can encapsulate refresh behavior.

public sealed class OAuthTokenProvider : ITokenProvider
{
    private OAuthToken? currentToken;

    public async Task<string> GetAccessTokenAsync(
        CancellationToken cancellationToken)
    {
        if (currentToken is null ||
            IsExpiringSoon(currentToken))
        {
            currentToken =
                await RefreshAsync(
                    currentToken,
                    cancellationToken);
        }

        return currentToken.AccessToken;
    }

    private static bool IsExpiringSoon(
        OAuthToken token)
    {
        return token.ExpiresAt <=
            DateTimeOffset.UtcNow.AddMinutes(2);
    }

    private static Task<OAuthToken> RefreshAsync(
        OAuthToken? token,
        CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

The refresh operation should be implemented using the OAuth provider's documented token endpoint and response format.

Do not assume that every OAuth provider returns identical fields or supports identical refresh semantics.

Preventing Concurrent Refreshes

A common production problem occurs when several requests detect an expiring token simultaneously.

A simple in-process solution is SemaphoreSlim.

private readonly SemaphoreSlim refreshLock =
    new(1, 1);

Then:

private async Task<OAuthToken> GetOrRefreshAsync(
    CancellationToken cancellationToken)
{
    await refreshLock.WaitAsync(
        cancellationToken);

    try
    {
        if (currentToken is not null &&
            !IsExpiringSoon(currentToken))
        {
            return currentToken;
        }

        currentToken =
            await RefreshAsync(
                currentToken,
                cancellationToken);

        return currentToken;
    }
    finally
    {
        refreshLock.Release();
    }
}

The important detail is that the token state is checked again after acquiring the lock.

Without that second check, every waiting request could still perform another refresh.

Handling Multiple Application Instances

SemaphoreSlim only coordinates requests within one application process.

Consider:

Server A ──> Refresh
Server B ──> Refresh
Server C ──> Refresh

A distributed application may need distributed coordination.

Possible approaches include:

  • Centralized token storage

  • Distributed locks

  • Database-based coordination

  • Distributed cache coordination

The right choice depends on the deployment architecture.

Do not assume an in-memory lock provides protection across multiple application instances.

Handling Refresh Token Rotation

Some OAuth implementations may issue a new refresh token during refresh.

Therefore, do not blindly preserve the previous refresh token.

Conceptually:

Old Refresh Token
       |
       v
Refresh Request
       |
       v
New Access Token
+
Possibly New Refresh Token

If the response provides a replacement refresh token, persist the new value securely.

A token update operation should therefore update the complete token state atomically.

Retrying the Original Request

Suppose an API request receives:

401 Unauthorized

The application can attempt:

API Request
    ↓
401
    ↓
Refresh Token
    ↓
New Access Token
    ↓
Retry Once

The retry limit should be explicit.

Avoid:

while (true)
{
    // Request
    // Refresh
    // Retry
}

An authentication failure should never create an infinite retry loop.

A safer pattern is:

if (response.StatusCode ==
    HttpStatusCode.Unauthorized &&
    !hasRetried)
{
    await RefreshTokenAsync(
        cancellationToken);

    return await SendAsync(
        request,
        hasRetried: true,
        cancellationToken);
}

Only retry when the failure is consistent with an expired or invalid access token.

Avoid Retrying Every 401

A 401 response does not necessarily mean the access token has simply expired.

Possible causes include:

  • Invalid credentials

  • Incorrect authentication configuration

  • Revoked authorization

  • Insufficient permissions

  • Application configuration problems

Therefore:

401
 |
 +---- Token expired?
 |       |
 |      Yes → Refresh
 |
 +---- Otherwise → Investigate

Blindly refreshing on every authentication failure can hide configuration problems.

Handling Refresh Failure

Refresh operations can fail.

For example:

try
{
    currentToken =
        await RefreshAsync(
            currentToken,
            cancellationToken);
}
catch (HttpRequestException ex)
{
    logger.LogError(
        ex,
        "OAuth token refresh failed.");

    throw;
}

The log should contain enough information for troubleshooting without exposing the access or refresh token.

If refresh fails because authorization has been revoked or the refresh token is no longer valid, the application may need to require the user to authenticate again.

Using HttpClient Correctly

A dedicated HttpClient should be managed through .NET's HTTP client infrastructure rather than repeatedly creating and disposing clients for every request.

For example:

public sealed class GitHubTokenClient
{
    private readonly HttpClient httpClient;

    public GitHubTokenClient(
        HttpClient httpClient)
    {
        this.httpClient = httpClient;
    }

    public async Task<string> RefreshAsync(
        string refreshToken,
        CancellationToken cancellationToken)
    {
        using var request =
            new HttpRequestMessage(
                HttpMethod.Post,
                "oauth/access_token");

        // Build the request according to
        // the provider's OAuth requirements.

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

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync(
            cancellationToken);
    }
}

The endpoint and request format should come from the provider's current OAuth documentation rather than being copied from an unrelated OAuth implementation.

Logging and Observability

Authentication workflows should be observable without exposing credentials.

Useful fields include:

Refresh Started
Refresh Succeeded
Refresh Failed
Token Expiration Window
HTTP Status
Request Correlation ID
Retry Attempt

Avoid:

Access Token
Refresh Token
Authorization Header
Client Secret

For distributed systems, correlation IDs and trace identifiers are particularly useful for determining whether multiple servers attempted refresh simultaneously.

Token Refresh and Caching

Caching can reduce unnecessary refresh operations.

A simple in-memory cache may work for a single-instance application:

private OAuthToken? cachedToken;

For a multi-instance application, token state may need to be stored in a shared secure data store.

However, token caching introduces its own requirements:

  • Encryption at rest

  • Access control

  • Expiration handling

  • Concurrency control

  • Rotation handling

  • Secure deletion

Do not use a general-purpose cache without considering whether its storage and access model are appropriate for sensitive credentials.

Common Mistakes

Hard-Coding Tokens

Credentials should never be embedded in application source code.

Refreshing on Every Request

Always check token validity before performing unnecessary refresh operations.

Using Only an In-Memory Lock in a Distributed System

An in-process lock cannot coordinate multiple application instances.

Ignoring Refresh Token Rotation

If a provider returns a replacement refresh token, failing to persist it can break future refresh operations.

Retrying Forever

Authentication retries must have strict limits.

Logging Authorization Headers

Logs can have a much wider audience than the application itself.

Assuming Every 401 Means Expiration

Authentication failures have multiple possible causes.

Best Practices

  1. Keep token management behind a dedicated abstraction.

  2. Store credentials using secure secret-management mechanisms.

  3. Refresh slightly before expiration when appropriate.

  4. Re-check token state after acquiring a refresh lock.

  5. Use distributed coordination when multiple instances share token state.

  6. Handle refresh-token rotation correctly.

  7. Retry an expired-token request at most once.

  8. Do not refresh blindly for every 401.

  9. Keep secrets out of logs and telemetry.

  10. Make refresh failures observable.

  11. Store token state atomically.

  12. Provide a clear re-authentication path when refresh is no longer possible.

Advantages and Disadvantages

Advantages

  • Reduces repeated user authentication

  • Supports long-running integrations

  • Helps background processes maintain authorization

  • Centralizes authentication logic

  • Can improve application reliability

  • Supports controlled retry behavior

Disadvantages

  • Refresh tokens become sensitive long-lived credentials

  • Concurrent refresh operations require coordination

  • Multi-instance deployments add complexity

  • Token rotation must be handled correctly

  • Revoked authorization still requires user interaction

  • Incorrect retry logic can hide authentication problems

Troubleshooting OAuth Refresh

When token refresh fails, check the problem systematically:

  1. Confirm the access token is actually expired or near expiration.

  2. Verify the OAuth application's configuration.

  3. Check whether the refresh token is still valid.

  4. Confirm that the token endpoint and request format are correct.

  5. Check the HTTP response status.

  6. Inspect non-sensitive error details.

  7. Verify that the application's clock is accurate.

  8. Check for concurrent refresh operations.

  9. Confirm that rotated refresh tokens are being persisted.

  10. Determine whether the user needs to authorize the application again.

For distributed applications, also determine whether another application instance refreshed or replaced the token state at approximately the same time.

Example Production Flow

A robust implementation can follow this sequence:

Application Request
       ↓
Get Current Token
       ↓
Token Expiring?
   /          \
 No            Yes
 |              |
 v              v
API Request   Acquire Lock
                 ↓
            Re-check Token
                 ↓
            Refresh Token
                 ↓
            Store New State
                 ↓
               API
                 ↓
          401 Unauthorized?
             /       \
           No         Yes
           |           |
           v           v
        Return     Refresh Once
        Response        |
                        v
                   Retry Request
                        |
                        v
                   Return Result

The flow keeps authentication management separate from business logic while preventing uncontrolled retries.

Conclusion

OAuth token refresh is easy to demonstrate but requires careful engineering in a long-running production integration. The biggest risks are not the basic HTTP request itself; they are credential protection, concurrent refreshes, token rotation, retry behavior, distributed deployments, and failure recovery.

A robust .NET implementation should isolate token management behind a dedicated service, store credentials securely, refresh before expiration when appropriate, coordinate concurrent refresh operations, and avoid exposing sensitive authentication data through logs or telemetry.

The most important principle is to treat access and refresh tokens as security-sensitive state rather than ordinary configuration values. With explicit expiration handling, controlled retries, and proper concurrency management, long-lived GitHub OAuth integrations can remain reliable without compromising credential security.