ASP.NET WebAPI + ReactJS - short-lived JWTs + refresh tokens (stored in Redis) OR Cookie-based auth + ASP.NET Identity
04:45 25 Feb 2026

A few months ago I developed a student project (ASP.NET 8 + React + SQL Server). The project is of the booking.com type (much more simplified, of course!), with the difference that accommodation units that are NOT accessible to people with disabilities cannot be added. In its initial version, I plan for it to be purely informational. Later on, if I see that it has potential, I will add reservation functionality as well. I want to resume work on it and turn it into a fully real / professional website.

For authentication, I used short-lived JWTs + refresh tokens (I stored them in Redis), but I’ve read that for similar projects, Cookie-based authentication + ASP.NET Identity is preferred.

I’m leaving the RedisTokenStore code here for you to take a look at.

public class RedisTokenStore(
    ILogger logger,
    IConnectionMultiplexer multiplexer) : ITokenStore
{
    private readonly IDatabase database = multiplexer.GetDatabase();

    private const string RefreshKeyPrefix = "auth:rt:";    // rt:{hash}
    private const string UserIndexPrefix = "auth:u:";     // u:{userId}:rt

    // Hash fields
    private static readonly RedisValue FUserId = "uid";
    private static readonly RedisValue FExpires = "exp"; // unix seconds
    private static readonly RedisValue FSession = "sid";

    // Lua script for atomic rotation:
    // - check old exists
    // - check owner matches
    // - delete old + remove from user set
    // - create new hash + set expireat + add to user set
    private static readonly LuaScript RotateScript = LuaScript.Prepare(@"
    -- KEYS[1] = oldKey
    -- KEYS[2] = newKey
    -- KEYS[3] = userSetKey
    -- ARGV[1] = userId
    -- ARGV[2] = expUnix (seconds)
    -- ARGV[3] = sessionId ('' if none)

    if redis.call('EXISTS', KEYS[1]) == 0 then
      return 0
    end

    local uid = redis.call('HGET', KEYS[1], 'uid')
    if uid ~= ARGV[1] then
      return -1
    end

    redis.call('DEL', KEYS[1])
    redis.call('SREM', KEYS[3], KEYS[1])

    redis.call('HSET', KEYS[2], 'uid', ARGV[1], 'exp', ARGV[2])

    if ARGV[3] ~= '' then
      redis.call('HSET', KEYS[2], 'sid', ARGV[3])
    else
      redis.call('HDEL', KEYS[2], 'sid')
    end

    local exp = tonumber(ARGV[2])

    redis.call('EXPIREAT', KEYS[2], exp)

    redis.call('SADD', KEYS[3], KEYS[2])

    redis.call('EXPIREAT', KEYS[3], exp, 'GT')

    return 1
");

    //chacked
    public async Task StoreRefreshTokenAsync(
        string refreshTokenHash,
        string userId,
        DateTimeOffset expiresAtUtc,
        string? sessionId = null,
        CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();

        var rtKey = RefreshKey(refreshTokenHash);
        var userSetKey = UserIndexKey(userId);

        var expUnix = expiresAtUtc.ToUnixTimeSeconds();

        // Store refresh token record
        var entries = sessionId is not null
            ? [new(FUserId, userId), new(FExpires, expUnix), new(FSession, sessionId)]
            : new HashEntry[] { new(FUserId, userId), new(FExpires, expUnix) };

        try
        {
            // Pipeline for fewer roundtrips
            var batch = database.CreateBatch();

            var setHashTask = batch.HashSetAsync(rtKey, entries);
            var setExpireTask = batch.KeyExpireAsync(rtKey, expiresAtUtc.UtcDateTime);
            var addIndexTask = batch.SetAddAsync(userSetKey, rtKey.ToString());

            // Keep the user index key around at least as long as the token TTL.
            // Optional but keeps housekeeping simpler.
            var indexExpireTask = batch.KeyExpireAsync(
                userSetKey,
                expiresAtUtc.UtcDateTime,
                ExpireWhen.GreaterThanCurrentExpiry);

            batch.Execute();

            await Task
                .WhenAll(setHashTask, setExpireTask, addIndexTask, indexExpireTask)
                .ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "RedisTokenStore.StoreRefreshToken failed. UserId: {UserId}", userId);
            throw;
        }
    }

    //chacked
    public async Task TryGetRefreshTokenAsync(
        string refreshTokenHash,
        CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();

        var rtKey = RefreshKey(refreshTokenHash);

        try
        {
            // HMGET uid exp sid
            var values = await database
                .HashGetAsync(rtKey, [FUserId, FExpires, FSession])
                .ConfigureAwait(false);

            if (values.Length != 3 ||
                values[0].IsNullOrEmpty ||
                values[1].IsNullOrEmpty)
            {
                return null;
            }

            var userId = values[0].ToString();

            if (long.TryParse(values[1].ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var expUnix) == false)
            {
                return null;
            }

            var expiresAt = DateTimeOffset.FromUnixTimeSeconds(expUnix);

            // If Redis TTL is correct, this shouldn't happen often, but it's a safe guard.
            if (expiresAt <= DateTimeOffset.UtcNow)
            {
                await RevokeRefreshTokenAsync(refreshTokenHash, ct);

                logger.LogDebug("Expired refresh token encountered and cleaned up. Key: {Key}", rtKey);

                return null;
            }

            var sessionId = values[2].IsNullOrEmpty
                ? null
                : values[2].ToString();

            return new RefreshTokenRecord(userId, expiresAt, sessionId);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "RedisTokenStore.TryGetRefreshToken failed. Key: {Key}", rtKey);
            throw;
        }
    }

    //chacked
    public async Task RevokeRefreshTokenAsync(
        string refreshTokenHash,
        CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();

        var rtKey = RefreshKey(refreshTokenHash);

        try
        {
            // Need userId to remove from the user index set.
            var userId = await database
                .HashGetAsync(rtKey, FUserId)
                .ConfigureAwait(false);

            // Delete token key
            await database
                .KeyDeleteAsync(rtKey)
                .ConfigureAwait(false);

            // Best-effort cleanup from index
            if (userId.IsNullOrEmpty == false)
            {
                var userSetKey = UserIndexKey(userId!.ToString());
                
                await database
                    .SetRemoveAsync(userSetKey, rtKey.ToString())
                    .ConfigureAwait(false);
            }
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "RedisTokenStore.RevokeRefreshToken failed. Key: {Key}", rtKey);
            throw;
        }
    }

    //chacked
    public async Task RotateRefreshTokenAsync(
        string oldRefreshTokenHash,
        string newRefreshTokenHash,
        string userId,
        DateTimeOffset newExpiresAtUtc,
        string? sessionId = null,
        CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();

        var oldKey = RefreshKey(oldRefreshTokenHash);
        var newKey = RefreshKey(newRefreshTokenHash);
        var userSetKey = UserIndexKey(userId);

        var expUnix = newExpiresAtUtc.ToUnixTimeSeconds();
        var sid = sessionId ?? string.Empty;

        var oldHashPrefix = Prefix(oldRefreshTokenHash);
        var newHashPrefix = Prefix(newRefreshTokenHash);

        try
        {
            var redisResult = await RotateScript.EvaluateAsync(
                database,
                new
                {
                    KEYS = new RedisKey[] { oldKey, newKey, userSetKey },
                    ARGV = new RedisValue[] { userId, expUnix, sid }
                }
            ).ConfigureAwait(false);


            // Robust parse
            var resultAsString = redisResult.ToString();

            if (int.TryParse(resultAsString, out var result) == false)
            {
                logger.LogError(
                    "RedisTokenStore.RotateRefreshToken returned non-int result. UserId: {UserId}, OldHash: {OldHash}, NewHash: {NewHash}, ExpUnix: {ExpUnix}, Result: {Result}",
                    userId,
                    oldHashPrefix,
                    newHashPrefix,
                    expUnix,
                    resultAsString);

                return false;
            }

            return result switch
            {
                1 => true,
                -1 => LogOwnerMismatchAndReturnFalse(userId, oldHashPrefix),
                _ => false
            };
        }
        catch (Exception ex)
        {
            logger.LogError(
                ex,
                "RedisTokenStore.RotateRefreshToken failed. UserId: {UserId}, OldKey: {OldKey}, NewKey: {NewKey}, ExpUnix: {ExpUnix}",
                userId,
                oldHashPrefix,
                newHashPrefix,
                expUnix);

            throw;
        }
    }

    //chacked
    public async Task RevokeAllRefreshTokensForUserAsync(
        string userId,
        CancellationToken ct = default)
    {
        ct.ThrowIfCancellationRequested();

        var userSetKey = UserIndexKey(userId);

        try
        {
            var members = await database
                .SetMembersAsync(userSetKey)
                .ConfigureAwait(false);

            if (members.Length == 0)
            {
                // Still delete the set key just in case
                await database
                    .KeyDeleteAsync(userSetKey)
                    .ConfigureAwait(false);

                return;
            }

            // Delete all rt:* keys and then the index set key
            var keysToDelete = members
                .Select(m => (RedisKey)m.ToString())
                .Append(userSetKey)
                .ToArray();

            var deleted = await database
                .KeyDeleteAsync(keysToDelete)
                .ConfigureAwait(false);

            logger.LogInformation(
                "RevokeAllRefreshTokens: attempted={Attempted}, deleted={Deleted}, userId={UserId}",
                members.Length,
                deleted,
                userId);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "RedisTokenStore.RevokeAllRefreshTokensForUser failed. UserId: {UserId}", userId);

            throw;
        }
    }

    private static RedisKey RefreshKey(string refreshTokenHash)
        => $"{RefreshKeyPrefix}{refreshTokenHash}";

    private static RedisKey UserIndexKey(string userId)
        => $"{UserIndexPrefix}{userId}:rt";

    private static string Prefix(string s, int len = 8)
        => string.IsNullOrEmpty(s) ? string.Empty : s[..Math.Min(len, s.Length)];

    private bool LogOwnerMismatchAndReturnFalse(string userId, string oldHashPrefix)
    {
        logger.LogWarning(
            "RefreshToken owner mismatch. Possible theft attempt. UserId: {UserId}, OldHashPrefix: {OldHashPrefix}",
            userId,
            oldHashPrefix);

        return false;
    }
}

Should I switch to Cookie-based auth + ASP.NET Identity or continue sticking with JWT + refresh tokens?

asp.net-web-api