{"record":{"id":"55158dfe2f588f43","repo":"fullstackhero/dotnet-starter-kit","slug":"refresh-token-is-invalid-or-expired","errorCode":null,"errorMessage":"refresh token is invalid or expired","messagePattern":"refresh token is invalid or expired","errorType":"exception","errorClass":"UnauthorizedException","httpStatus":401,"severity":"error","filePath":"src/Modules/Identity/Modules.Identity/Services/IdentityService.cs","lineNumber":246,"sourceCode":"\n    private async Task<FshUser> FindUserByRefreshTokenAsync(string refreshToken, string tenantId, CancellationToken ct)\n    {\n        var hashedToken = HashToken(refreshToken);\n\n        if (_logger.IsEnabled(LogLevel.Debug))\n        {\n            _logger.LogDebug(\n                \"Validating refresh token for tenant {TenantId}. Token hash: {TokenHash}\",\n                tenantId, hashedToken[..Math.Min(8, hashedToken.Length)]);\n        }\n\n        var user = await _userManager.Users\n            .FirstOrDefaultAsync(u => u.RefreshToken == hashedToken, ct);\n\n        if (user is null)\n        {\n            _logger.LogWarning(\"No user found with matching refresh token hash for tenant {TenantId}\", tenantId);\n            throw new UnauthorizedException(\"refresh token is invalid or expired\");\n        }\n\n        return user;\n    }\n\n    private void ValidateRefreshTokenExpiry(FshUser user)\n    {\n        var now = _timeProvider.GetUtcNow().UtcDateTime;\n        if (user.RefreshTokenExpiryTime <= now)\n        {\n            _logger.LogWarning(\n                \"Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}\",\n                user.Id, user.RefreshTokenExpiryTime, now);\n            throw new UnauthorizedException(\"refresh token is invalid or expired\");\n        }\n    }\n\n    private static void ValidateUserStatus(FshUser user)","sourceCodeStart":228,"sourceCodeEnd":264,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs#L228-L264","documentation":"During token refresh, FindUserByRefreshTokenAsync hashes the presented refresh token and searches users by the stored RefreshToken hash. If no user matches, it throws UnauthorizedException(\"refresh token is invalid or expired\") — a single generic message used both for unknown and expired tokens.","triggerScenarios":"POST to the refresh endpoint with a token that was never issued, already rotated (old token superseded by a newer one), cleared from the user row, or issued to a different environment/database.","commonSituations":"Client kept a stale refresh token after a refresh rotation replaced it; database reseeded so stored hashes no longer match; two frontends sharing tokens across dev/staging databases; tokens stored client-side got truncated or re-encoded (URL-encoding issues).","solutions":["Fall back to full re-authentication: obtain a fresh token pair via the login endpoint.","Ensure the client stores the LATEST refresh token returned by each refresh call (rotation invalidates the previous one).","Verify the client is calling the same environment/database the token was issued from."],"exampleFix":"// before: persist old token after refresh\nlocalStorage.setItem('refreshToken', oldToken);\n// after\nlocalStorage.setItem('refreshToken', response.refreshToken); // newly rotated value","handlingStrategy":"fallback","validationCode":"const canRefresh = !!auth.refreshToken && auth.refreshToken === auth.lastIssuedRefreshToken;\nif (!canRefresh) return loginAgain();","typeGuard":"function hasRefreshToken(a: unknown): a is { refreshToken: string } {\n  return typeof a === 'object' && a !== null && typeof (a as any).refreshToken === 'string' && (a as any).refreshToken.length > 0;\n}","tryCatchPattern":"catch (ApiError e) when (e.StatusCode === 401 && e.Message.includes('refresh token')) {\n  clearStoredTokens();\n  redirectToLogin(); // fall back to interactive auth\n}","preventionTips":["Persist the rotated refresh token returned by every refresh response.","Namespace tokens per environment so dev/staging tokens never cross over.","On app start, prefer silent refresh over replaying an arbitrarily old token."],"tags":["auth","refresh-token","http-401"],"backgroundTag":"jwt-token-expired","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}