← Programming
ProgrammingApplied

JWT + Refresh Tokens

How access and refresh tokens actually work together. Token rotation, revocation, and a few security decisions I made without fully realizing how important they were until I wrote this.

jwtrefresh-tokensdotnetsecurityauth

Why two tokens instead of one

A single long-lived JWT is simple but has one bad property: it can't be revoked. Once issued, it's valid until it expires — logout doesn't actually invalidate it, since the server never checks a database on every request; that's the whole point of a JWT being stateless.

So the split is: a short-lived access token (60 minutes here) that the API trusts without a DB lookup on every request, and a longer-lived refresh token (7 days) that does hit the database every time it's used. The access token is fast and stateless. The refresh token is the thing that's actually revocable, because it's checked against real data on every use.

Setting up JWT auth

public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration config)
{
    var jwt_settings = config.GetSection("jwt").Get<JwtSettings>()
                       ?? throw new InvalidOperationException("jwt configuration is missing.");
 
    if (string.IsNullOrWhiteSpace(jwt_settings.signing_key))
        throw new InvalidOperationException("jwt:signing_key is required.");
 
    var signing_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt_settings.signing_key));
 
    services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = signing_key,
                ValidateIssuer = !string.IsNullOrWhiteSpace(jwt_settings.issuer),
                ValidIssuer = jwt_settings.issuer,
                ValidateAudience = !string.IsNullOrWhiteSpace(jwt_settings.audience),
                ValidAudience = jwt_settings.audience,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            };
 
            options.Events = new JwtBearerEvents
            {
                OnChallenge = async context =>
                {
                    context.HandleResponse();
                    context.Response.StatusCode = 401;
                    context.Response.ContentType = "application/json";
                    var result = ApiResponse.Unauthorized();
                    await context.Response.WriteAsync(JsonSerializer.Serialize(result));
                }
            };
        }).AddCookie("HangfireAuth", options =>
        {
            options.LoginPath = "/hangfire-login";
            options.Cookie.Name = "Hangfire.Auth";
            options.ExpireTimeSpan = TimeSpan.FromHours(2);
            options.Cookie.HttpOnly = true;
            options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        });
 
    services.AddAuthorization();
    return services;
}

Two decisions here I want to actually call out, because I almost didn't notice I'd made them:

Generating the access token

public LoginTokenResponseDTO GenerateAccessToken(User user)
{
    var issued_at_utc = DateTime.UtcNow;
    var expires_at_utc = issued_at_utc.AddMinutes(GetTokenLifetimeMinutes());
    var user_id = user.id.ToString();
 
    var claims = new List<Claim>
    {
        new(JwtRegisteredClaimNames.Sub, user_id),
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new(ClaimTypes.NameIdentifier, user_id),
        new(ClaimTypes.Email, user.email),
        new("user_status", user.user_status.ToString())
    };
 
    var token = new JwtSecurityToken(
        issuer: string.IsNullOrWhiteSpace(_jwt_settings.issuer) ? null : _jwt_settings.issuer,
        audience: string.IsNullOrWhiteSpace(_jwt_settings.audience) ? null : _jwt_settings.audience,
        claims: claims,
        notBefore: issued_at_utc,
        expires: expires_at_utc,
        signingCredentials: _signing_credentials);
 
    return new LoginTokenResponseDTO
    {
        access_token = new JwtSecurityTokenHandler().WriteToken(token),
        token_type = "Bearer",
        expires_at_utc = expires_at_utc
    };
}

The Jti claim (JWT ID) is a unique identifier per token — I'm not using it for anything yet, but it's the hook you'd need if you ever wanted to blacklist a specific access token before it naturally expires (rare, since access tokens are short-lived anyway, but it's there).

The refresh flow — this is the part with real logic in it

public async Task<ApiResponse<dynamic>> RefreshAsync(RefreshTokenRequestDTO token_dto, RequestContext request_context)
{
    try
    {
        var current_date = DateTime.UtcNow;
        var provided_refresh_token = !string.IsNullOrWhiteSpace(token_dto.refresh_token)
            ? token_dto.refresh_token
            : request_context.RefreshToken;
 
        if (string.IsNullOrWhiteSpace(provided_refresh_token))
            throw new AppException("A valid refresh token is required. Please provide a valid token.", 400);
 
        var hashed_old_refresh_token = LibraryHelper.HashText(provided_refresh_token, _settings.encryption.key);
 
        await _uow.BeginAsync();
        var transaction = _uow.Transaction ?? throw new InvalidOperationException("Transaction is not initialized.");
 
        var token_data = await _user_repository.GetRefreshTokenByHashAsync(hashed_old_refresh_token, _uow.Connection, transaction);
        if (token_data == null)
            throw new AppException("A valid refresh token is required. Please provide a valid token.", 400);
 
        var user_id = token_data.user_id;
 
        if (token_data.revoked_at != null)
        {
            _uow.Commit();
            return ApiResponse<dynamic>.NotOk(null, "Token already expired.");
        }
 
        if (token_data.expires_at < DateTime.UtcNow)
        {
            _uow.Commit();
            return ApiResponse<dynamic>.NotOk(null, "Token already expired.");
        }
 
        var user_data = await _user_repository.GetUserByIdAsync(user_id, _uow.Connection, transaction);
 
        var new_refresh_token = Guid.NewGuid();
        var hashed_refresh_token = LibraryHelper.HashText(new_refresh_token.ToString(), _settings.encryption.key);
        var refresh_token_expiry_time = current_date.AddDays(GetRefreshLifetimeDays());
 
        var user_refresh_token_data = new UserRefreshToken
        {
            session_id = token_data.session_id,
            refresh_token_hash = hashed_refresh_token,
            expires_at = refresh_token_expiry_time,
            ip_address = request_context.IpAddress,
            device_info = JsonSerializer.Serialize(request_context.UserAgent),
            revoked_at = null,
            created_at = current_date,
            last_used_at = current_date,
            user_id = user_id
        };
 
        var new_access_token = _token_service.GenerateAccessToken(user_data);
 
        await _user_repository.InsertRefreshTokenAsync(user_refresh_token_data, _uow.Connection, transaction);
        await _user_repository.RevokeOldTokenAsync(token_data.id, "rotated", _uow.Connection, transaction);
 
        var user_profile = await _user_repository.GetUserPublicData(user_data.id, _uow.Connection, transaction);
 
        var return_data = new
        {
            access_token = new_access_token,
            refresh_token = new_refresh_token.ToString(),
            refresh_token_expires_at = refresh_token_expiry_time,
            user_data = user_profile
        };
        _uow.Commit();
        return ApiResponse<dynamic>.Ok(return_data, "");
    }
    catch
    {
        _uow.Rollback();
        throw;
    }
}

The design decisions actually hiding in this code

I never store the raw refresh token. Only LibraryHelper.HashText(...)goes into the database. Same principle as password hashing — if the database ever leaks, the stored value is useless to an attacker without knowing the original token. Refresh tokens are already high-entropy random values (unlike passwords, which need a slow hash like bcrypt to resist brute-force), so a fast keyed hash is appropriate here — I'm not trying to slow down an attacker's guessing rate, I'm just making the stored value unusable on its own.

Rotation: every refresh issues a brand-new refresh token and kills the old one. InsertRefreshTokenAsync for the new one, RevokeOldTokenAsync(..., "rotated") for the old one — in the same transaction, so I never end up in a state with two valid refresh tokens for the same session, or a revoke that succeeded while the insert failed.

The "already expired" message is the same whether the token is actually expired or was revoked. I didn't originally notice this was a good thing, but it is: if I told a client "this token was revoked" specifically, that leaks information — it tells an attacker holding a stolen, already-used token that it's being actively tracked/rotated, rather than just naturally timing out. One generic message for both cases doesn't give that away.

Everything runs inside one DB transaction, via the unit-of-work pattern (_uow.BeginAsync() / Commit() / Rollback()). Reading the old token, inserting the new one, and revoking the old one either all succeed or all roll back together — no partial state where a new token exists but the old one wasn't revoked.

What I don't have yet — and this is the real gap

The thing this code doesn't do: detect reuse of an already-revoked token as a theft signal. Right now, if someone presents a refresh token that's already been rotated (i.e. it was valid once, got used, and is now revoked), my code just returns "already expired" and stops. That's technically correct, but it's a missed opportunity — if a revoked token gets reused, that's a strong signal someone stole a refresh token before I rotated it out from under them. The standard response in a mature system is to treat that as a compromise signal and revoke the entire session (or all of that user's refresh tokens), not just silently reject the one bad request.

I haven't built that yet. It's the natural next step on this same code, not a new topic — I'd add a check like "if token_data.revoked_at != null AND revoked_reason == 'rotated', treat this as suspicious and revoke every active token for token_data.user_id."

Sources & further reading