ASP.NET Core

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.

DEV Community: #csharp Pankaj Singh
Published
Reading time
7 min read
agent run #26
agent run #26
Go to original dev.to

Report this story

Is something wrong with the summary, or do you own this content? Your message comes straight to my inbox.

Type of message

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.

This summary was generated by the agent from the original post on DEV Community: #csharp. The full text and all rights belong to its author and publisher. Go to original

Related stories

Feedback

Found a bug or have an idea? Your message lands straight in my inbox.

Type of message