DEV Community

Cover image for From Permanent Tokens to a Server-Authoritative JWT Model in .NET
scubaDEV
scubaDEV

Posted on

From Permanent Tokens to a Server-Authoritative JWT Model in .NET

A lot of integrations start the same way: you get an access token from a third-party API, you store it somewhere, and you keep using it. It works on day one. The trouble shows up later — the token can't be revoked without a redeploy, every instance of your app trusts it blindly, and when several requests try to refresh it at the same time you get a small storm of duplicate refresh calls.

This post walks through replacing that pattern with a server-authoritative model: the server is the single source of truth for token state, tokens are short-lived, and revocation is a database write instead of a deployment.

The problem with "permanent" tokens

A permanent or very long-lived token has three properties you don't want in production:

  1. It can't be revoked cheaply. If it leaks, your only real lever is rotating it manually and pushing config everywhere.
  2. Trust is implicit. Any holder of the token is authorized, and the application has no internal record of whether that token should still be valid.
  3. Refreshes race. Under load, multiple callers notice the token is expired at the same instant and all trigger a refresh.

The fix for all three is to stop treating the token as a static secret and start treating it as state your server owns.

The shape of the solution

The model has three moving parts:

  • A database table that holds the current token, its expiry, and a validity flag.
  • A token manager that is the only component allowed to read, refresh, or invalidate the token.
  • Per-tenant concurrency control so that only one refresh happens at a time, even with many concurrent callers.

Here's a minimal version of the persisted state:

public class AccessTokenRecord
{
    public int CompanyId { get; set; }
    public string AccessToken { get; set; } = string.Empty;
    public DateTimeOffset ExpiresAt { get; set; }
    public bool IsValid { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The token manager

The manager centralizes everything. Nothing else in the codebase talks to the token table directly — that single rule is what makes the model "authoritative."

public class TokenManager
{
    private readonly AppDbContext _db;
    private readonly IExternalAuthClient _auth;

    // One semaphore per company so refreshes don't stampede,
    // but different companies don't block each other.
    private static readonly ConcurrentDictionary _locks = new();

    public TokenManager(AppDbContext db, IExternalAuthClient auth)
    {
        _db = db;
        _auth = auth;
    }

    public async Task GetValidTokenAsync(int companyId, CancellationToken ct = default)
    {
        var record = await _db.AccessTokens
            .FirstOrDefaultAsync(t => t.CompanyId == companyId, ct);

        if (record is { IsValid: true } && record.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
            return record.AccessToken;

        return await RefreshAsync(companyId, ct);
    }

    private async Task RefreshAsync(int companyId, CancellationToken ct)
    {
        var gate = _locks.GetOrAdd(companyId, _ => new SemaphoreSlim(1, 1));
        await gate.WaitAsync(ct);
        try
        {
            // Re-check inside the lock: another caller may have just refreshed.
            var record = await _db.AccessTokens
                .FirstOrDefaultAsync(t => t.CompanyId == companyId, ct);

            if (record is { IsValid: true } && record.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
                return record.AccessToken;

            var fresh = await _auth.RequestTokenAsync(companyId, ct);

            record ??= new AccessTokenRecord { CompanyId = companyId };
            record.AccessToken = fresh.Token;
            record.ExpiresAt = fresh.ExpiresAt;
            record.IsValid = true;

            _db.AccessTokens.Update(record);
            await _db.SaveChangesAsync(ct);

            return record.AccessToken;
        }
        finally
        {
            gate.Release();
        }
    }

    public async Task InvalidateAsync(int companyId, CancellationToken ct = default)
    {
        var record = await _db.AccessTokens
            .FirstOrDefaultAsync(t => t.CompanyId == companyId, ct);

        if (record is null) return;

        record.IsValid = false;
        await _db.SaveChangesAsync(ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details are doing the heavy lifting here.

The double-check inside the lock. The first caller to find an expired token enters the semaphore and refreshes. Every other caller that was waiting then re-reads the record after acquiring the lock, finds it's already fresh, and returns immediately. You pay for exactly one refresh, not one per waiting request.

One semaphore per tenant. A single global lock would serialize refreshes across all companies, turning an unrelated tenant's slow auth call into everyone's problem. Keying the semaphore by companyId isolates them.

Why short-lived beats permanent

Once token state lives in the database, expiry stops being scary. A token that lives 30 minutes and refreshes automatically is strictly safer than one that lives forever, because the blast radius of a leak is now measured in minutes. And revocation becomes trivial: flipping IsValid to false means the next call refreshes, no redeploy required.

A note on invalidation triggers

InvalidateAsync is worth wiring to real events rather than calling it ad hoc. Good triggers include the external API returning a 401, an admin disconnecting an integration, or a credential rotation. The pattern is always the same: mark the record invalid, let the next GetValidTokenAsync transparently fetch a new one.

Wrapping up

The shift here is conceptual more than technical. You stop thinking of the token as a secret you hold and start thinking of it as state your server is responsible for. Once you make that move, short lifetimes, clean revocation, and safe concurrency all fall out of the same small design — a database record, one manager, and a per-tenant lock.

Top comments (0)