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:
Old Refresh Token
|
v
Refresh Request
|
v
New Access Token
+
Possibly New Refresh TokenIf 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 UnauthorizedThe application can attempt:
API Request
↓
401
↓
Refresh Token
↓
New Access Token
↓
Retry OnceThe 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 → InvestigateBlindly 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 AttemptAvoid:
Access Token
Refresh Token
Authorization Header
Client SecretFor 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
Keep token management behind a dedicated abstraction.
Store credentials using secure secret-management mechanisms.
Refresh slightly before expiration when appropriate.
Re-check token state after acquiring a refresh lock.
Use distributed coordination when multiple instances share token state.
Handle refresh-token rotation correctly.
Retry an expired-token request at most once.
Do not refresh blindly for every
401.Keep secrets out of logs and telemetry.
Make refresh failures observable.
Store token state atomically.
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:
Confirm the access token is actually expired or near expiration.
Verify the OAuth application's configuration.
Check whether the refresh token is still valid.
Confirm that the token endpoint and request format are correct.
Check the HTTP response status.
Inspect non-sensitive error details.
Verify that the application's clock is accurate.
Check for concurrent refresh operations.
Confirm that rotated refresh tokens are being persisted.
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 ResultThe 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.

Comments
Join the conversation! Your thoughts help the community grow.