JWT access and refresh tokens in ASP.NET Core (.NET 10): rotation, revocation and the bugs to avoid
A seasoned ASP.NET Core developer shares the token flow he ships: 15-minute signed JWT access tokens, opaque refresh tokens stored only as a SHA-256 hash, rotation on every refresh, and family-wide revocation when a rotated token reappears. The post also lists the configuration mistakes he keeps fixing in other people's APIs on .NET 10 with EF Core.
Key points
- Access tokens live 15 minutes, refresh tokens 7 days, and only a SHA-256 hash of the refresh value is persisted
- Every refresh rotates: the old row is revoked and linked to the replacement via ReplacedByTokenHash
- Presenting an already-revoked refresh token revokes every active token for that user
- Cut the default 5-minute ClockSkew to 30 seconds, especially with 15-minute access tokens
- Leaving MapInboundClaims at its default makes User.FindFirst("sub") return null
- Rate-limit login and refresh, and keep the signing key in user-secrets or Key Vault with ValidateOnStart
The design splits two token types: a 15-minute, stateless signed JWT access token that every [Authorize] endpoint validates, and a 7-day opaque refresh token built from random bytes. The refresh token never goes inside the JWT, and the server stores only its SHA-256 hash behind a unique index. Lifetimes and the signing key live in a typed JwtOptions class validated at startup, while JwtBearer is configured with MapInboundClaims = false and a 30-second ClockSkew.
The refresh endpoint does rotation plus reuse detection: issuing a new pair revokes the old row and links it through ReplacedByTokenHash, and if a revoked token shows up again the service assumes theft and kills every active refresh token for that user. The post also flags recurring mistakes, from storing raw refresh tokens and skipping rotation to trusting the default 5-minute clock skew, leaving MapInboundClaims on so "sub" lookups return null, and committing the symmetric key to git. Using TimeProvider instead of DateTime.UtcNow keeps expiry testable.
Real-world use
- A mobile-backed SaaS API where sessions must survive a phone reboot without permanent tokens
- A customer portal with logins on several devices, where a suspected token theft should end all sessions at once
- An API whose refresh endpoint sees heavy traffic and needs rate limits against credential stuffing
- Hardening an existing ASP.NET Core API by adding rotation and reuse detection to its auth layer
Why it matters
Storing refresh tokens in the clear or skipping rotation turns a single database leak or XSS into a permanent account takeover; this pattern is the smallest amount of code that limits the damage after a leak.
Related stories
- Role-based access to reports and folders in ASP.NET Core: users, roles and admin claimsDEV Community: #aspnetcore4 d ago
- Issuing and validating JWT access tokens in ASP.NET CoreDEV Community: #aspnetcore4 d ago
- ASP.NET Core Authentication & Authorization Explained: What to Use, When, and How to Ship It SafelyDEV Community: #aspnetcore6 d ago