Table of Contents

Refresh tokens and the session

What a refresh returns, what happens when a refresh token is presented twice, and how a client's tokens can be tied to the user's session here. The code on this page is included from samples/Server.Tokens. The exchange itself is recorded in the refresh token flow.

new Client
{
    ClientId = "spa",
    RequireClientSecret = false,
    AllowedGrantTypes = { OidcConstants.GrantTypes.AuthorizationCode },
    RedirectUris = { "https://spa.example.com/callback" },
    AllowedScopes = { "openid", "profile", "orders", "offline_access" },
    AllowOfflineAccess = true,

    // Ask the profile service again on every refresh, so a role granted or withdrawn since sign-in reaches
    // the next access token. Off, a refresh hands back the access token it was issued with, renewed.
    UpdateAccessTokenClaimsOnRefresh = true,

    // Signing out removes this client's tokens from the session; with server-side sessions, its refresh and
    // access tokens stop working once the session has ended.
    CoordinateLifetimeWithUserSession = true,
},

What a refresh hands back

Changed in 0.99.0, and a host relying on claims being re-read on refresh has to opt in. A refresh token keeps the access token issued with it, per resource indicator, and by default a refresh hands that token back with its creation time set to now and its lifetime renewed: the claims — jti among them — are the ones it was issued with, and the profile service is not asked for them again. That is Duende 8.0.1's default, measured from its TokenResponseGenerator. Until 0.99.0 every refresh built the token afresh.

UpdateAccessTokenClaimsOnRefresh re-runs the profile service on every refresh, so a role granted or withdrawn since sign-in reaches the next access token. The profile service's IsActiveAsync is asked on every refresh either way, so a deactivated user's refresh is refused. A refresh token issued before 0.99.0 keeps no access token; its first refresh builds one and keeps it. A profile service can tell a refresh from other grants by the request it is handed — see the profile service.

A scope the client may no longer have

New in 0.176.0, a security fix, measured from Duende 8.0.1. The scopes a refresh token carries are validated again as the client's on every refresh, and so are an authorization code's when it is redeemed: a scope withdrawn from AllowedScopes, a disabled or deleted identity resource or API scope, or offline_access once AllowOfflineAccess is off, refuses the request invalid_scope ("Invalid scope."), as Duende's TokenRequestValidator answers. Until 0.176.0 the result was not read: the stored scopes were copied onto the new tokens, and an administrator's withdrawal took effect only when the user's refresh token expired. The device and CIBA grants have always refused it.

Who is affected: a user whose client lost a scope since they signed in. Their next refresh is refused and the client signs them in again, for what it may now have. A client that should keep working through such a change asks for fewer scopes, not more, at the next sign-in.

A refresh token used twice

A OneTimeOnly refresh token (the default, RefreshTokenUsage) presented again after it was rotated away is a replay: refused, and its whole lineage revoked (RFC 9700 §4.14.2), with RefreshTokenReplayDetectedEvent raised. That needs the consumed token to be kept, which is why PersistentGrants.DeleteOneTimeOnlyRefreshTokensOnUse — Duende's, new in 0.150.0 — is off here, where Duende's default is on: a deleted token presented again is only unknown, so the replay revokes nothing.

Two requests with one refresh token at once

New in 0.176.0, a security fix (ours; Duende reads and consumes in two steps, as this did). A OneTimeOnly refresh token is taken by the request answered with it: the token endpoint consumes it in one step once every check on the request has passed and before any token is issued (TryRedeemAsync). Until 0.176.0 two requests presenting it at the same moment could both read it unconsumed and both be answered with a successor — two live lineages from one token, and no replay detected, which is what a thief racing the client would do. Now the request that loses is answered exactly as a presentation just after the winner's: within the grace period below it is accepted; otherwise it is refused invalid_grant, the lineage revoked and RefreshTokenReplayDetectedEvent raised. With DeleteOneTimeOnlyRefreshTokensOnUse the loser is refused and nothing more, as an unknown handle is. An authorization code is taken the same way: of two requests redeeming one code, one is answered and the other is invalid_grant.

The stores do the taking: TryRemoveAsync and TryConsumeAsync, new in 0.176.0, which the in-memory and Dapper stores answer in one step — the Dapper store with a DELETE or an UPDATE … WHERE ConsumedTime IS NULL and the rows it changed. No schema change.

Security — a grant store of your own. The new members have default bodies, so a store written before 0.176.0 compiles and behaves as before — and so does the race: the default reads, then removes or writes back. Implement both in one statement of your database (a conditional delete or update that reports the rows it changed) to close it. The same holds for an IAuthorizationCodeStore or IRefreshTokenStore of your own, whose new TryRemoveAuthorizationCodeAsync, TryConsumeRefreshTokenAsync and TryRemoveRefreshTokenAsync default to the old two-step behaviour; and for an IRefreshTokenService of your own, whose TryRedeemAsync defaults to taking nothing.

A grace period for a reused refresh token

New in 0.167.0, off by default. A BFF can replay its own refresh token without any theft — two instances refreshing with the same token at the same moment, or a request arriving with the old cookie just after a refresh landed — and each such race signs the user out. PersistentGrants.ConsumedRefreshTokenGracePeriod lets a consumed token through for that long after it was consumed. From configuration, for a host that binds its IdentityServer section to the options:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "IdentityServer": {
    "IssuerUri": "https://sts.example.com",
    "PersistentGrants": {
      "ConsumedRefreshTokenGracePeriod": "00:00:30"
    }
  }
}

or in code, options.PersistentGrants.ConsumedRefreshTokenGracePeriod = TimeSpan.FromSeconds(30).

  • Within the window the request succeeds as for a current token — a fresh access token and a fresh refresh token, so the lineage forks and both forks stay valid. Everything else is checked as before: the client, the absolute and sliding expiry, the session where lifetimes are coordinated, the profile service's IsActiveAsync. A token whose lineage has been revoked is gone, and never accepted. An accepted reuse is logged at information ("Accepting refresh token consumed … ago, within the consumed refresh token grace period of …"); no event is raised.
  • The window is measured from the first consumption and does not move: the reused token is not consumed again, so presenting it repeatedly cannot hold the window open.
  • After the window, reuse detection is unchanged: refused, the lineage revoked — every fork with it — and RefreshTokenReplayDetectedEvent raised, now with Duende's warning logged ("Rejecting refresh token because it has been consumed already.").
  • Revoking a refresh token revokes its lineage while the window is on (the token it was rotated from and any fork), so a client that signs out leaves nothing acceptable behind; with it off, the one handle, as before.

The trade-off: it widens the replay window by exactly the grace period. A copied refresh token redeemed within it is not recognised as a replay — the thief gets a fork of their own, and nothing is revoked. Keep it as short as the race it covers. For a BFF like the platform's Shell, 30 seconds: both races are milliseconds wide on the BFF itself, but the second lasts as long as a request already carrying the old cookie can take to arrive, and the window is measured on one authorization server instance's clock and checked on another's, so it has to absorb a slow request and some clock skew too. Thirty seconds does, and is small beside the 15-minute access tokens it protects. A public client (a SPA, a native app) holding its refresh token directly gains less and risks more; leave it off there. Zero — the default — is the behaviour before 0.167.0; negative is refused by UseIdentityServer. It has no effect with DeleteOneTimeOnlyRefreshTokensOnUse, which leaves no consumed token to accept.

Duende difference: Duende 8.0.1 has no option: its DefaultRefreshTokenService.AcceptConsumedTokenAsync answers false and a host overrides it for a grace period. Measured and taken: a consumed token the hook accepts goes on through UpdateRefreshTokenAsync, which stamps ConsumedTime only when it has none and always stores a new handle — so the window does not move and the lineage forks. Until 0.167.0 this server re-stamped ConsumedTime on every consumption, which for an override accepting a reuse moved its window; now it does not. Ours: the option, which the default hook reads (a host's override still replaces it); the information log on an accepted reuse (Duende's logs nothing there); the lineage revoked on revocation while the window is on (Duende's removes the one handle). Not breaking: with the option unset every request is answered as before; DefaultRefreshTokenService keeps both its constructors, and the logger comes in through a third, which the container builds; TokenRevocationResponseGenerator keeps its constructor, and the options come in through an overload. ConsumedTime is the PersistedGrants column both Dapper dialects already have — no schema change.

A sliding refresh token

With RefreshTokenExpiration Sliding, a refresh token lives SlidingRefreshTokenLifetime seconds past its latest use, and never past AbsoluteRefreshTokenLifetime from when the lineage was first issued — unless that is zero, which for a sliding token is no cap at all: it expires only once it goes unused for the sliding lifetime. Under OneTimeOnly the extended lifetime goes to the successor; under ReUse the same handle is written back in place, its stored expiry moved with it. That is Duende 8.0.1's DefaultRefreshTokenService.UpdateRefreshTokenAsync, measured, which slides a token whatever its usage.

Fixed in 0.174.0, two ways. A ReUse sliding token did not slide here: it expired at its first lifetime however often it was used, because only the rotation path recomputed the lifetime and the in-place write kept the stored expiry. And an AbsoluteRefreshTokenLifetime of zero capped a sliding token at its own creation, so such a client's refresh tokens were issued already expired. A client on ReUse with Sliding now keeps a session that is in use, as it was configured to; nothing else moves. An IRefreshTokenStore of a host's own whose UpdateRefreshTokenAsync keeps the row's expiry still compiles and keeps the old behaviour — write the expiry as the token's CreationTime plus Lifetime, as the default now does, to slide.

Tokens that end with the session

New in 0.99.0. CoordinateLifetimeWithUserSession (or, for every client that does not say, Authentication.CoordinateClientLifetimesWithUserSession; both off by default, as in Duende) ties a client's tokens to the user's session here:

  • Signing out removes the client's refresh tokens, reference tokens and codes from that session.
  • With server-side sessions, a refresh token is refused invalid_grant, and an access token invalid_token wherever this server validates one, once no unexpired session is left for its subject and session id; while one is, using the token slides the session forward — and, with Authentication.CookieSlidingExpiration on (0.101.0), the browser's session cookie with it.

Access tokens carry the session they were issued in as sid, which is what the check keys on; identity tokens carry it too, since 0.100.0, as Duende's do, so a relying party can match a logout token or a front-channel notification to the session it holds.

A session that expires or is ended by an administrator goes further than Duende's default: every grant issued in it is revoked and every client notified, coordinating or not, as it has been since server-side sessions shipped. That is deliberate and recorded in the parity matrix. Until 0.99.0 it reached less than it said — a session this server signed in recorded no session id on its row, and a refresh token recorded none either, so neither was found when the session ended. Both now are.