.NET
What I Learned Implementing JWT Authentication in .NET
Access token expiration is easy to handle. The refresh token expiring silently, with no explicit failure path, is what actually caused confusing logouts until I gave it one.
Access tokens expiring is the easy part to handle. My client catches a 401, calls the refresh endpoint, gets a new access token, and moves on. The part that actually gets me is the refresh token itself expiring, silently, with nothing telling the client it happened until it tries to use it and fails.
Where this actually breaks
The access token has a short lifetime on purpose, so I built the refresh flow around that from the start. What I didn't build around at first was the refresh token also having its own, longer expiration. When that one runs out, the refresh call itself fails, and if I'm only handling the access token failing, that second failure has nowhere to go. The user just gets logged out with no clear reason why, in the middle of whatever they were doing.
What I'm changing
The refresh endpoint needs its own explicit failure path, separate from a normal 401 on a regular request. If the refresh token is expired or invalid, that has to route somewhere specific, back to a login screen with an actual message, not just a generic auth error that looks identical to any other failed request.
public async Task<IActionResult> Refresh(RefreshRequest request)
{
var storedToken = await _tokenStore.FindAsync(request.RefreshToken);
if (storedToken is null || storedToken.ExpiresAt < DateTimeOffset.UtcNow)
{
return Unauthorized(new { reason = "refresh_token_expired" });
}
var newAccessToken = _tokenService.CreateAccessToken(storedToken.UserId);
return Ok(new { accessToken = newAccessToken });
}
That reason field is the piece I was missing. Without it, the client has no way to tell "your access token expired, try refreshing" apart from "your refresh token is dead, log in again." Both come back as the same generic failure otherwise.
What actually stuck from this
Token expiration is really two separate events on two different timers, and only one of them has an obvious recovery path built into the normal flow. The other one needs to be handled on purpose, or it just shows up as a confusing silent logout with no clear cause.