Architecture Decision Record
ADR-097: Multi-Device Refresh Sessions (Hashed, Rotating, Per Device)
Status
Accepted (2026-08-26). Supersedes the storage model of ADR-050 (one plaintext refresh-token column on the user row); the rotation and reuse-detection policy that record decided is kept and generalized to a per-device set.
Context
ADR-050 stores a user's refresh token as a single nullable RefreshToken string plus its
RefreshTokenExpiry on the app's User aggregate. That model settles rotation and reuse detection
correctly and costs three things it names but cannot fix from inside itself.
The token is a bearer credential kept in plaintext. Anything that can read the Identity database (a backup, a support query, a log of a row dump, a compromised read replica) can mint access tokens for any user who is signed in, because the stored value is exactly what the client presents.
There is one slot per user, so a session is an account-wide fact rather than a device fact.
Signing in on a phone overwrites the laptop's token, and the laptop's next refresh presents a value
that no longer matches, which the same record's reuse rule then treats as theft: the second device
is not merely signed out, it is signed out through the compromise path. ADR-050 records this as a
trade-off and names "a per-device or per-session token table" as the thing that would fix it
(050-jwt-refresh-token-rotation.md:114-118). The contract itself now says the same thing from the
other side: refresh tokens are "deliberately absent" from IAuthUser, with the reason written into
the interface (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:9-14).
And a single column has no history. Rotation overwrites, so the row cannot say what replaced what, who signed out, when, or from where. A replayed token and an expired one are indistinguishable after the fact, which leaves an operator with nothing to look at after a reported account compromise.
Decision
Refresh tokens become rows in their own table: one row per signed-in device, hashed at rest, chained on rotation.
RefreshSessionis a flat framework record, not an aggregate.MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:31carriesId,UserId,TokenHash,CreatedAt,ExpiresAt,RevokedAt,ReplacedByTokenHash,ReasonRevoked, and the optionalIpAddress/UserAgent(:58-92). LikeOutboxMessageandAuditTrailEntryit has no audit stamps, no soft-delete flag and no concurrency token: rows are never edited except to be revoked, and a global query filter hiding a revoked row would break the reuse check that depends on finding it (:22-29).- The store holds a hash, never a token.
RefreshSession.HashTokenis SHA-256 over the token's UTF-8 bytes, hex encoded in upper case (:160-164), andCreatehashes on the way in so the plaintext never reaches a property (:139, factory at:112-145). The digest is deliberately unsalted and deterministic, because every lookup is by hash: a salted digest could not be found (:11-15). The encoding is part of the contract rather than an implementation detail, and the method's remarks give the byte-for-byte SQL Server equivalent,CONVERT(char(64), HASHBYTES('SHA2_256', CONVERT(varchar(max), Token)), 2), so a consumer's data migration can reproduce it (:151-157); the digest width is a constant the mapping reads (:34). - Rotation leaves a walkable chain.
Revoke(revokedAt, reason, replacedByTokenHash)records the successor's hash (:174-189, the link at:186), and refuses to revoke an already-revoked session rather than overwriting the first reason and instant recorded (:176-182). The four reasons are constants on the entity:Rotated,SignedOut,ReuseDetected,SessionCapExceeded(:46-55). - Reuse detection revokes the live family, and only on the right signal.
AuthenticationServiceBase<TUser>(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:45) resolves a presented token to its session (:464-496) and separates three rejections that all answer the caller with the sameAuth.InvalidRefreshTokenfailure (:603-604). An unknown hash (or one belonging to another account) fails alone (:479-482), because revoking the family on it would let anyone holding one of a user's expired access tokens sign them out everywhere by posting a random string. A revoked row means this exact token was already rotated away or signed out and has come back, which is the reuse signal that revokes every live session the user holds (:484-491, the family sweep at:566-577). An expired row is an ordinary end of life: that device re-authenticates and the user's other devices keep working (:493-495). The three are argued together in the method's own summary (:454-463). - Sign-out has both scopes.
RevokeTokenAsync(userId, refreshToken)signs out one device when the token resolves to a live session of that user (:320-358, the per-device branch at:343-351); an unknown token, another account's token or an already-revoked row leaves the caller unidentifiable, so the request degrades to signing every device out rather than reporting success for a revocation that reached nothing (:339-342, fall-through at:354-355).RevokeAllSessionsAsync(userId)is the explicit everywhere case, for a password change, an admin lockout or a "sign out everywhere" action (:361-376; the contract states both scopes at.../Application/Auth/IAuthenticationService.cs:57-61,71-74).AuthControllerBase'sPOST auth/revokecarries no body, so it cannot name the device it is called from and deliberately signs out everywhere (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:143-160, call at:155); a consumer wanting per-device sign-out callsRevokeTokenAsyncfrom its own action, which the endpoint's own documentation says (:133-142). - A configurable cap bounds the table without ever failing a login.
RefreshSessions:MaxActiveSessionsPerUser(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:31, default 10,[Range(1, 1000)]at:30, reasoning at:23-29; the base property falls back to the same 10 for an unbound options instance,AuthenticationServiceBase.cs:96-105, constant at:57) is enforced before a new session is staged: while the user is at or over the cap, the oldest live session is revoked with reasonSessionCapExceeded(:584-597, the eviction loop at:593-596). Ordering isCreatedAtthenId(:589-590, matched by the store's own ordering,.../Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:61-69), so two sessions opened in the same clock tick still evict deterministically. Expired-but-unrevoked rows do not count against the cap: they authenticate nobody (AuthenticationServiceBase.cs:579-583, filter at:588). - IP and user-agent capture is optional and informational.
AuthControllerBasereads them from the connection and the request headers (AuthControllerBase.cs:56,:62) and passes them into login, registration and refresh (:79,:104,:125), and the entity truncates them to their column widths of 45 and 512 (RefreshSession.cs:142-143, widths at:37,:40). Neither value is ever part of a validation decision, so a mobile client changing networks is not signed out (:84-88). - Mapping is opt-in per data source.
RefreshSessionSettings.Enableddefaults tofalse(RefreshSessionSettings.cs:21, reasoning at:14-20), so a host that has not opted in keeps the model it had and its migrations never see the table.ApplicationDbContextmaps it only whenEnabledis true and the context instance's physical source name equalsRefreshSessions:DataSourceName(defaultDefault,RefreshSessionSettings.cs:48, reasoning at:33-46), the same two-part gate the scheduler table uses (.../Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:296-298, rationale at:293-295, applied at:357and:659-667). That keeps the table in exactly one database in a host that splits its modules across sources, instead of putting an emptyRefreshSessionstable in every module's migrations. A host with its own context class calls the publicApplyRefreshSessionConfigurationdirectly (.../Persistence/Auth/RefreshSessionModelBuilderExtensions.cs:34, the opt-in-unlike-the-outbox argument at:8-14); Cosmos never reaches either path, because its context overridesOnModelCreating(ApplicationDbContext.cs:648-649). - The shipped store routes to that same database.
EFRefreshSessionStore(.../Persistence/Auth/EFRefreshSessionStore.cs:30-33) resolves the physical source through the entity registry first (a consumer that ships a real entity configuration for the session entity is routed like any other entity), falling back to the source named byDataSourceName(:75-78, reasoning at:14-23), and is registered scoped beside its bound options (.../MMCA.Common.Infrastructure/DependencyInjection.cs:147-151). Every read is tracked on purpose:IRefreshSessionStorereturns instances the caller revokes by mutating, and a no-tracking read would drop those revocations at save time (.../Application/Auth/IRefreshSessionStore.cs:15-17, restated atEFRefreshSessionStore.cs:24-28). - The table carries exactly two indexes, because it answers exactly two questions: a unique index
on
TokenHash(IX_RefreshSessions_TokenHash,RefreshSessionModelBuilderExtensions.cs:64-66, name at:22), the validation path, unique so a hash collision across users cannot validate one account's token against another's session (:60-63); and(UserId, RevokedAt)(IX_RefreshSessions_UserId,:70-71, name at:25), the family path used by the cap, by reuse detection and by sign-out-everywhere (:68-69).TokenHashis fixed-length non-unicode, because the value is always a 64-character hex digest (:45-49, reasoning at:43-44). - Design time has its own flag, and it must agree with the host.
DesignTimeDbContextOptions.EnableRefreshSessions(defaultfalse,.../Persistence/DbContexts/Design/DesignTimeDbContextOptions.cs:73) belongs in the Identity migrations project only (:57-72).DesignTimeDbContextHelper.CreateSqlServerregisters the settings with the source name this context actually resolved to (.../Design/DesignTimeDbContextHelper.cs:101-106), so the gate opens for exactly the context--datasourceselected, including a logical name that collapses ontoDefault(:96-100). A flag that disagrees with the host'sRefreshSessions:Enabledshows up ashas-pending-model-changes(DesignTimeDbContextOptions.cs:69-71). - The access token carries
suband nothing else that names the user.TokenServicemintsJwtRegisteredClaimNames.Subas the single carrier of the user id (.../MMCA.Common.Infrastructure/Services/TokenService.cs:92); the duplicate custom claim that used to ride alongside it is gone, so there are no longer two values that can disagree and two claim names every reader has to know (:86-89).AuthClaimTypes.Subjectnames the claim (.../MMCA.Common.Shared/Auth/AuthClaimTypes.cs:25) andClaimsPrincipalExtensionsreads bothsuband theNameIdentifierform the JWT bearer handler maps it to (.../Shared/Auth/ClaimsPrincipalExtensions.cs:26-28), parsing throughIParsableso the solution-wide identifier alias (ADR-048) can change shape without editing the readers (:40-43). RS256 tokens now carry the JWKSKeyIdin theirkidheader (TokenService.cs:66, stamped at:212), so a validator reading the published JWKS document (ADR-004) selects the right key by name instead of trying each in turn (:208-211, the same id on the validation key at:232-234).
Rationale
- A credential at rest is a credential. Hashing is what turns a database read from "mint tokens
for every signed-in user" into "hold a list of digests". The unsalted digest is the deliberate part:
the token is 64 bytes of
RandomNumberGeneratoroutput (TokenService.cs:117-121), not a guessable password, so the property a salt buys (resistance to offline guessing of the input) is worth nothing here, while the property it costs (lookup by hash) is the entire access path (IRefreshSessionStore.cs:26-35). - One row per device is what a session actually is. The single column made "signed in" an account
fact and forced every second device through the compromise path. Rows make it a device fact, which
is what both the user's mental model and any future "your devices" screen need
(
RefreshSession.cs:7-10). - A rotation chain is what makes replay detectable at all. Because using a session revokes it and
records its successor, a replayed token lands on a revoked row instead of on nothing, and "revoked"
is a signal an unknown hash can never produce (
RefreshSession.cs:16-21, and the store returning revoked rows on purpose,IRefreshSessionStore.cs:26-31). That distinction is what lets reuse revoke the family while a random string cannot. - Failing closed on reuse, open on the unknown. Both branches return the same error, so a caller
learns nothing about which one it hit (
AuthenticationServiceBase.cs:599-604), but they behave differently where it matters: the branch an attacker can reach at will (post a random token) is the one that revokes nothing. - A cap that evicts beats a cap that refuses. Refusing the eleventh sign-in would fail a
legitimate login to protect a table; evicting the oldest live session bounds the growth and costs
the user the device they used least recently (
RefreshSessionSettings.cs:23-29). - Opt-in mapping is what keeps this one module's data. Sessions belong to Identity. The outbox is
configured on the base context because it is genuinely cross-cutting; copying that would have put an
empty table in every other database's migrations
(
RefreshSessionModelBuilderExtensions.cs:8-14). - The behavior is pinned by tests at all three layers: the entity's hashing, creation and
revocation rules
(
MMCA.Common/Tests/Core/MMCA.Common.Domain.Tests/Auth/RefreshSessionTests.cs:13), the login, rotation, reuse and cap workflow (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Auth/AuthenticationServiceBaseTests.cs:24, hash-only storage at:164, other devices left alone at:178and:534, cap eviction at:194, rotation at:507, replay revoking the family at:552, expiry and unknown tokens failing alone at:573and:596, per-device and all-device sign-out at:645,:661and:677), and the mapping (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Auth/RefreshSessionModelBuilderExtensionsTests.cs:14).
Trade-offs
- This is a breaking change with a data migration attached.
IAuthUserlosesRefreshToken,RefreshTokenExpiry,UpdateRefreshTokenandRevokeRefreshToken(IAuthUser.cs:9-14, the interface now being password material only at:16-25), so every consumer'sUseraggregate changes shape. The migration path is expand then contract (ADR-057): create theRefreshSessionstable, carry the live tokens over by hashing them in place with the SQL equivalent ofHashToken(which is why the encoding is documented as a contract,RefreshSession.cs:151-157), and only then drop the two user columns, with theEXPAND-CONTRACT-OVERRIDEmarker that drop requires. A consumer that skips the carry step is not broken, but every signed-in user is signed out at deploy. - Reuse detection still revokes a family on a benign race. Two client tabs refreshing near
simultaneously, the second presenting the just-rotated-away token, is indistinguishable from theft
and now signs out every device rather than one (
AuthenticationServiceBase.cs:484-491). This is ADR-050's aggressive-by-design trade-off with a wider blast radius, kept deliberately: the alternative is a grace window in which a genuinely stolen token works. - Nothing ages the table out. Revoked and expired rows are kept, and the framework ships no
retention sweep for
RefreshSessionsthe way it does for the outbox and the audit trail; the code says as much, naming a sweep "the consumer schedules" (AuthenticationServiceBase.cs:581-582). A consumer that wants them tidied schedules its own job (ADR-074). The cap bounds only the live set. - Two gates have to agree, and only a scaffold says when they do not.
RefreshSessions:Enableddrives the runtime model (ApplicationDbContext.cs:296-298) andEnableRefreshSessionsdrives the design-time one (DesignTimeDbContextOptions.cs:73); a mismatch produces no startup error, just a migration that does not match the running model (:69-71). - The refresh path writes more than it did. A rotation inserts one row and revokes another
(
AuthenticationServiceBase.cs:535-563), and every issue reads the user's live set to enforce the cap (:584-591), where the previous model wrote one column. The reads are index-covered (RefreshSessionModelBuilderExtensions.cs:70-71), but the refresh endpoint is no longer a single-row update. IpAddressandUserAgentare captured and nothing in the framework reads them. No shipped endpoint lists a user's devices (AuthControllerBase.csmaps login, register, refresh and revoke only,:67-160), so today the two columns exist for a support query and for the screen a consumer may build (RefreshSession.cs:84-88).- The hash is confirmable, by design. Anyone holding both a database read and a candidate token
can verify the pairing, since the digest is deterministic and unsalted (
RefreshSession.cs:160-164). That is the accepted cost of lookup-by-hash and it holds only because the input is high-entropy random; the same scheme applied to anything guessable would be wrong.
Related
ADR-050 (the single-column model this record replaces, and the
source of the rotation and reuse-detection policy it keeps),
ADR-004 (the stateless RS256 access token this flow reissues, and
the JWKS document the new kid header points into),
ADR-006 (one sealed context class per engine, which is why the mapping
gate lives on the base context rather than in a consumer subclass),
ADR-029 (the lockout and rate-limit checks that run
before a session is ever opened, in the same shared workflow,
AuthenticationServiceBase.cs:120-125),
ADR-047 (the middleware that bounds the access token's
revocation gap; a soft-deleted user's sessions stop refreshing because the refresh flow re-fetches
through the same query filter, which is why the delete handler does not revoke them itself,
.../Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:82-86),
ADR-051 (the client half: the rotated pair each head persists
and replays),
ADR-057 (the gate the column drop has to be marked
for),
ADR-048 (the identifier alias the sessions and the sub
reader are typed against).