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
ApplicationWhen an access token expires:
Application
|
v
Access Token Expired
|
v
Refresh Token
|
v
New Access TokenThe 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 TokenWithout 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 ──> RefreshA 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:

Join the conversation! Your thoughts help the community grow.