fullstackhero/dotnet-starter-kit · error · UnauthorizedException

user not found

Error message

user not found

What it means

StoreRefreshTokenAsync performs an ExecuteUpdate on users matching the given id, setting RefreshToken (hash) and expiry. If zero rows were updated the user does not exist (or is filtered out by tenant query filters), so it throws UnauthorizedException('user not found') instead of silently storing nothing.

Solutions

  1. Verify the userId parsed from the refresh token / claims exists (and in the requesting tenant) before the refresh flow.
  2. Force re-login: the client should treat this 401 as an unrecoverable session and clear stored tokens.
  3. Check tenant resolution on the refresh request matches the tenant the user belongs to.
  4. If users were wiped (re-seed), re-run migrations/seed and have all clients re-authenticate.

Example fix

// before
const res = await fetch('/api/v1/auth/refresh', { method: 'POST', body: JSON.stringify({ refreshToken }) });
// on 401 'user not found' retrying loops forever
// after
if (res.status === 401) {
  clearStoredTokens();
  redirectToLogin(); // session no longer valid
}
Defensive patterns

Strategy: fallback

Validate before calling

const sub = parseJwt(accessToken)?.sub;
if (!sub || !uuidRegex.test(sub)) { clearTokens(); redirectToLogin(); }

Type guard

function hasUsableSession(tokens) {
  const p = tokens?.accessToken ? parseJwt(tokens.accessToken) : null;
  return Boolean(p?.sub);
}

Try / catch

try { session = await refreshSession(refreshToken); }
catch (e) {
  if (e.status === 401) { clearStoredTokens(); redirectToLogin(); return null; } // 'user not found' is terminal
  throw e;
}

Prevention

When it happens

Trigger: Storing a refresh token for a userId that was deleted after the access token was issued; a token/subject referencing a user in another tenant; passing a malformed or wrong Guid derived from claims; race where the user is removed between token issue and refresh.

Common situations: Refresh flow after an admin deleted the account; multi-tenant deployment where the tenant header changed between login and refresh; identity re-seed wiping users while clients still hold tokens; decrypting a subject string into the wrong id.

Understand the failure class

Background: "User not found", "Invalid user", and "does not exist": what missing-user lookup errors mean across Rocket.Chat, LiteLLM, Phabricator, rustfs, and pnpm — this error's family across 10 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/ce3694e1a4370f27. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Identity/Modules.Identity/Services/IdentityService.cs:119

        return (user.Id, claims);
    }

    public async Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default)
    {
        // Targeted UPDATE bypasses tracking + Finbuckle interceptors (which NRE on cross-tenant IgnoreQueryFilters).
        // Safe: user IDs are globally unique GUIDs, so exactly one row matches Id == subject regardless of tenant.
        var hashedToken = HashToken(refreshToken);
        var updated = await _dbContext.Users
            .IgnoreQueryFilters()
            .Where(u => u.Id == subject)
            .ExecuteUpdateAsync(
                s => s.SetProperty(u => u.RefreshToken, hashedToken)
                      .SetProperty(u => u.RefreshTokenExpiryTime, expiresAtUtc),
                ct).ConfigureAwait(false);

        if (updated == 0)
        {
            throw new UnauthorizedException("user not found");
        }

        if (_logger.IsEnabled(LogLevel.Debug))
        {
            _logger.LogDebug(
                "Stored refresh token for user {UserId}. Token hash: {TokenHash}, Expires: {ExpiresAt}",
                subject, hashedToken[..Math.Min(8, hashedToken.Length)], expiresAtUtc);
        }
    }

    public async Task<(string Subject, IEnumerable<Claim> Claims)?>
        BuildClaimsForUserAsync(string userId, string tenantId, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(userId);
        ArgumentNullException.ThrowIfNull(tenantId);

        // IgnoreQueryFilters bypasses Finbuckle's tenant filter so root-tenant callers can
        // resolve users in other tenants during impersonation.

View on GitHub (pinned to 3f2959e683)