The Problem with Sessions
When we started building our microservices platform two years ago, we defaulted to cookie-based sessions. It felt comfortable — ASP.NET had great support for it, and we'd been using sessions forever. But as we split the monolith into independent services, cracks started showing fast.
Every new service needed access to the session store. We had to share the same Redis instance across eight different services. One slow Redis response caused auth failures across the whole platform. Scaling horizontally required sticky sessions, which killed our ability to do proper load balancing. We were building ourselves into a corner.
Enter JWT
JSON Web Tokens solve the statefulness problem elegantly. The token itself carries the claims — the server doesn't need to look anything up. A JWT signed with your private key can be verified by any service that has the corresponding public key. That's it. No shared session store.
Here's what a minimal JWT setup looks like in .NET 8:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)),
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = config["Jwt:Audience"],
ClockSkew = TimeSpan.Zero
};
});
The Short Access Token + Long Refresh Token Pattern
The naive JWT approach issues a long-lived token (say, 7 days) and calls it done. That's a security disaster — if someone grabs that token, they have a week of access with no way to revoke it without invalidating every token (because there's no server state).
The industry-standard fix is the dual-token pattern:
- Access token: Short-lived (15 minutes). Carried in the
Authorizationheader. Used for every API call. - Refresh token: Long-lived (7–30 days). Stored in an HttpOnly cookie. Used only to get a new access token.
When the access token expires, the client silently calls /auth/refresh with the refresh token cookie. If the refresh token is valid, the server issues a new pair and invalidates the old refresh token (rotation). If someone steals a refresh token and uses it, the next legitimate rotation will fail — and you can detect the attack.
Refresh Token Rotation
Token rotation is the key security property. Every time you exchange a refresh token, the old one is revoked and a new one is issued. We store a hash of the refresh token in the database with a revoked flag:
public class RefreshToken {
public long Id { get; set; }
public int UserId { get; set; }
public string TokenHash { get; set; } = string.Empty;
public bool Revoked { get; set; }
public DateTime ExpiresAt { get; set; }
}
If we detect a revoked refresh token being reused (replay attack), we immediately revoke all refresh tokens for that user — forcing them to log in again — and log the security event.
2FA Integration
We layer TOTP-based 2FA on top of the token flow. After validating username/password, we issue a short-lived "pre-2FA" refresh token with a two_fa_verified = false flag. The client then posts the TOTP code; on success we flip the flag and issue a full-access access token.
This keeps the 2FA state on the token itself — no session, no extra round-trips to a state store. The 2FA verification survives access-token rotation because it lives on the refresh token.
What We Learned
After running this pattern in production for 18 months across our microservices: access token leakage is now a 15-minute problem, not a week-long incident. Refresh token replay attacks are automatically detected and escalated. Every service validates tokens independently with zero shared infrastructure. Scaling is trivially horizontal.
The only real cost is the added complexity in the client refresh logic. It's worth every bit of it.