Why We Added JWT Refresh Tokens to the XAF Web API (and How)

The DevExpress XAF Web API gives you JWT authentication for free: POST your credentials
to /api/Authentication/Authenticate, get back a bearer token, attach it to every request.
For a demo, that's perfect. For anything you actually ship, the default token policy has a
problem — and it's the kind of problem that's invisible until a security review (or an
incident) makes it very visible.
This post is the write-up of egarim/XafApiRefreshToken,
a reference XAF solution that adds a proper refresh-token flow — short-lived access tokens,
long-lived rotating refresh tokens, reuse detection, and real revocation — on top of XAF's
existing security system. I'll start with why, because the "why" is the whole point.
The problem with the out-of-the-box token
Here's what you get by default, and why each line is a liability:
| Default XAF Web API | Why it hurts | |
|---|---|---|
| Access token lifetime | ~2 days | A leaked token is valid for days. |
| Storage | Stateless (nothing server-side) | The server has no record of issued tokens. |
| Revocation | None | You cannot invalidate a token before it expires. |
| Logout | None | "Log out" doesn't actually stop the token from working. |
| Expiry UX | Re-login | When it expires, the user must type their password again. |
The tension is fundamental: a JWT is a self-contained, signed claim. The API trusts it
because the signature checks out — it doesn't phone home. That's what makes JWTs fast and
scalable, and it's exactly why you can't revoke one. So you're pushed toward a long lifetime
(fewer annoying re-logins) which directly widens the blast radius if a token leaks. You can't
have "long-lived and revocable" with a bare access token. That's the trap.
The core idea: stop using one token for two jobs. Use a short-lived access token for
API calls (so a leak expires in minutes), and a separate long-lived refresh token —
tracked in your database, therefore revocable — whose only job is to mint new access tokens.
That single split is why refresh tokens exist, and it's what we implemented.
The design
┌─────────┐ 1. login (user + password) ┌──────────────────┐
│ Client │ ──────────────────────────────▶ │ /Authenticate │
│ │ ◀── access (15 min) + │ SignInManager │
│ │ refresh (7 days) │ + token service │
└─────────┘ └──────────────────┘
│ 2. API calls: Authorization: Bearer <access>
│ 3. access expires →
│ 4. POST /Refresh { refreshToken } → rotate: new pair, old revoked
│ 5. logout → POST /Revoke → refresh token killed server-side
- Access token: 15 minutes. Short enough that a leak is nearly worthless.
- Refresh token: 7 days, stored in the database. Because it's a row, not just a signature,
we can look it up, expire it, revoke it, and detect abuse. - Rotation: every refresh consumes the old refresh token and issues a brand-new one.
- Reuse detection: if a revoked refresh token is ever presented again, we revoke its
entire descendant chain — the signal of a stolen token being replayed.
All of it rides on top of XAF's existing SignInManager and security — we didn't replace
XAF's auth, we extended it.
The refresh token is an XPO business object
This is the part that makes it feel native to XAF: the refresh token is just a persistent
business object, so it lives in the same database, through the same ORM, as the rest of your
domain. The computed IsActive flag is the heart of every validation check:
[DefaultClassOptions]
public class RefreshToken : BaseObject
{
public RefreshToken(Session session) : base(session) { }
[Size(500)]
[Indexed(Unique = true)]
public string Token { get; set; } // 64 random bytes, base64
public DateTime Created { get; set; }
public DateTime Expires { get; set; }
public DateTime? Revoked { get; set; } // null = still live
[Size(50)] public string RevokedByIp { get; set; }
[Size(500)] public string ReplacedByToken { get; set; } // rotation chain
[Size(50)] public string CreatedByIp { get; set; }
[Association("User-RefreshTokens")]
public ApplicationUser User { get; set; }
[NonPersistent] public bool IsExpired => DateTime.UtcNow >= Expires;
[NonPersistent] public bool IsRevoked => Revoked != null;
[NonPersistent] public bool IsActive => !IsRevoked && !IsExpired;
}
Note ReplacedByToken — that field is what links a token to the one that replaced it, forming
a chain. Reuse detection walks that chain. And CreatedByIp / RevokedByIp give you an
audit trail for free.
Generating the pair
On login, after XAF's SignInManager validates the credentials, the service mints both tokens
and persists the refresh token via INonSecuredObjectSpaceFactory — the same object-space
mechanism XAF uses everywhere, just without the security filtering (we're writing an
infrastructure record, not user data):
public async Task<AuthenticateResponse> GenerateTokensAsync(ApplicationUser user, string ipAddress)
{
var accessTokenExpiration = DateTime.UtcNow.AddMinutes(
_configuration.GetValue<int>("Authentication:Jwt:AccessTokenExpirationMinutes", 15));
var refreshTokenExpiration = DateTime.UtcNow.AddDays(
_configuration.GetValue<int>("Authentication:Jwt:RefreshTokenExpirationDays", 7));
var accessToken = GenerateAccessToken(user, accessTokenExpiration);
var refreshTokenValue = GenerateRefreshTokenValue(); // RandomNumberGenerator.GetBytes(64)
using var os = _nonSecuredObjectSpaceFactory.CreateNonSecuredObjectSpace<RefreshToken>();
var dbUser = os.GetObjectByKey<ApplicationUser>(user.Oid);
var entity = os.CreateObject<RefreshToken>();
entity.Token = refreshTokenValue;
entity.Created = DateTime.UtcNow;
entity.Expires = refreshTokenExpiration;
entity.CreatedByIp = ipAddress;
entity.User = dbUser;
os.CommitChanges();
return new AuthenticateResponse {
AccessToken = accessToken,
RefreshToken = refreshTokenValue,
AccessTokenExpires = accessTokenExpiration,
RefreshTokenExpires = refreshTokenExpiration
};
}
The access token itself is a normal JWT — but notice it carries the user's XAF roles as
role claims (so [Authorize(Roles = ...)] works) plus a random SecurityStamp:
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Oid.ToString()),
new Claim(ClaimTypes.Name, user.UserName ?? string.Empty),
new Claim("SecurityStamp", Guid.NewGuid().ToString())
};
foreach (var role in user.Roles)
claims.Add(new Claim(ClaimTypes.Role, role.Name));
The refresh token, by contrast, is not a JWT — it's 64 bytes of cryptographic randomness.
It carries no claims because it's never trusted on its own; it's only ever a lookup key into
the database.
Rotation + reuse detection: the security payoff
This is where the database-backed design earns its keep. On /Refresh, we look up the token,
reject anything that isn't IsActive, and — crucially — if a revoked token is replayed, we
burn the whole chain:
public async Task<AuthenticateResponse> RefreshTokenAsync(string token, string ipAddress)
{
using var os = _nonSecuredObjectSpaceFactory.CreateNonSecuredObjectSpace<RefreshToken>();
var entity = os.FindObject<RefreshToken>(CriteriaOperator.Parse("Token = ?", token));
if (entity == null)
throw new InvalidOperationException("Invalid refresh token");
if (!entity.IsActive)
{
// A revoked token was presented again → likely a stolen, replayed token.
if (entity.IsRevoked)
{
RevokeDescendantTokens(entity, os, ipAddress, "Attempted reuse of revoked token");
os.CommitChanges();
}
throw new InvalidOperationException("Invalid refresh token");
}
var user = entity.User;
var newRefreshToken = RotateRefreshToken(entity, os, ipAddress); // new row, old revoked
os.CommitChanges();
var accessTokenExpiration = DateTime.UtcNow.AddMinutes(
_configuration.GetValue<int>("Authentication:Jwt:AccessTokenExpirationMinutes", 15));
return new AuthenticateResponse {
AccessToken = GenerateAccessToken(user, accessTokenExpiration),
RefreshToken = newRefreshToken.Token,
AccessTokenExpires = accessTokenExpiration,
RefreshTokenExpires = newRefreshToken.Expires
};
}
Why this matters: in a rotating scheme, a legitimate client always holds the newest refresh
token. If an attacker steals an old one and tries to use it, that token is already revoked
(the real client rotated past it) — and the reuse attempt trips the alarm, invalidating the
descendants so the attacker can't ride the chain forward. Rotation turns a silent theft into a
detectable, self-healing event.
/Revoke is then trivially a real logout: mark the row revoked, and the refresh token is
dead server-side — no waiting for expiry.
The lessons-learned (the stuff that actually costs you time)
The repo documents the rough edges, and they're the parts worth stealing:
ClockSkew = TimeSpan.Zero. ASP.NET Core's JWT validation allows a 5-minute clock-skew
tolerance by default. With a 15-minute token that's a 33% lie about its lifetime. Set it to
zero so "15 minutes" means 15 minutes.- HTTPS redirect only in production.
UseHttpsRedirection()in development turns local
http://API tests into redirect loops/failures. Gate it behindif (!env.IsDevelopment()). - The proxy/VPN
HttpClienttrap. On a machine with a corporate proxy or VPN,HttpClient
calls tolocalhostfail with cryptic SSL errors. The fix in the test client is
new SocketsHttpHandler { UseProxy = false }(plus HTTP/1.1) — bypass the proxy for loopback. - Friendly token errors beat bare 401s. The project adds a small middleware that detects the
classic mistakes — a token pasted with quotes, a doubledBearer Bearer …prefix, an empty
token — and returns a message that says what's wrong, instead of an opaque401. There's even
aGET /TestTokenendpoint that decodes your token and shows its expiry. Tiny touches, hours
saved.
Should you use this?
If you expose the XAF Web API to anything you don't fully control — a SPA, a mobile app, a
third-party integration — then yes, the default long-lived stateless token is not the policy
you want. Refresh tokens give you the trifecta that's otherwise impossible:
- Security — access tokens that expire in minutes, so a leak is short-lived.
- Control — a server-side record you can revoke on demand (real logout, real "sign out
everywhere"). - UX — sessions that last days and renew silently, so users aren't re-typing passwords.
And because it's built on XPO business objects + XAF's object spaces + the existing
SignInManager, it stays inside the framework you already know. Clone
egarim/XafApiRefreshToken, point it at your
database, and the RefreshToken table is created for you on first run.
Questions, or a different take on token lifetimes? Find me on the links on the
about page.