--- name: identityserver-token-lifecycle description: "Guide for implementing token types, refresh token management, token exchange (RFC 8693), extension grants, IProfileService claims customization, and token lifetime best practices in Duende IdentityServer." invocable: false --- # IdentityServer Token Types, Refresh Tokens, and Token Exchange ## When to Use This Skill - Choosing between JWT and reference access tokens for a client - Configuring refresh token rotation, sliding expiration, or replay detection - Implementing token exchange (RFC 8693) for impersonation or delegation - Building an extension grant validator (`IExtensionGrantValidator`) - Customizing which claims appear in identity tokens, access tokens, or userinfo responses via `IProfileService` - Setting token lifetime policies for access tokens and refresh tokens - Issuing internal tokens from extensibility code via `IIdentityServerTools` - Understanding identity tokens vs access tokens and their intended audiences Docs: https://docs.duendesoftware.com/identityserver/tokens ## Token Types Overview Duende IdentityServer issues three primary token types: | Token Type | Purpose | Audience | Format | | -------------- | -------------------------------------------------- | ------------------------------------- | ---------------- | | Identity Token | Communicates authentication event to the client | Client application only (`aud` claim) | Always JWT | | Access Token | Authorizes access to a protected resource (API) | API / Resource Server | JWT or Reference | | Refresh Token | Obtains new access tokens without user interaction | Token endpoint only | Opaque handle | ### Key Principles - Identity tokens are **solely for the client application** that initiated the authentication. Never send an identity token to an API. - Access tokens are for APIs. They contain client ID, scopes, expiration, and optionally user claims. - Refresh tokens enable long-lived API access by allowing the client to request new access tokens silently. ## Identity Tokens Identity tokens are JWTs that describe "what happened at the token service". They contain: - `iss` — the issuer (your IdentityServer URL) - `sub` — the authenticated user's unique identifier - `aud` — the client that requested authentication - `auth_time` — when the user authenticated - `amr` — authentication method (e.g., `pwd`) - `idp` — identity provider used (e.g., `local`) - `sid` — the session ID - `nonce` — ensures the token is consumed only once at the client ```json { "iss": "https://localhost:5001", "nbf": 1609932802, "iat": 1609932802, "exp": 1609933102, "aud": "web_app", "amr": ["pwd"], "nonce": "63745529591...I3ZTIyOTZmZTNj", "sid": "F6E6F2EDE86EB8731EF609A4FE40ED89", "auth_time": 1609932794, "idp": "local", "sub": "88421113", "name": "Bob" } ``` ## Access Tokens: JWT vs Reference ### JWT Access Tokens All claims are embedded in the token. The API validates the token by checking the signature using the issuer's public keys (from the JWKS endpoint). JWTs **cannot be revoked** before their expiration — the only invalidation mechanism is waiting for `exp`. ```json { "iss": "https://localhost:5001", "exp": 1609936401, "aud": "urn:resource1", "scope": "openid resource1.scope1 offline_access", "client_id": "web_app", "sub": "88421113", "jti": "2C56A356A306E64AFC7D2C6399E23A17" } ``` ### Reference Access Tokens Reference tokens are **pointers** to token data stored in the persisted grant store. The API must call the **introspection endpoint** to validate the token. Reference tokens support **immediate revocation** by deleting the stored data. ```csharp // Configure a client to use reference tokens client.AccessTokenType = AccessTokenType.Reference; ``` The API consuming reference tokens must have a secret configured on the `ApiResource`: ```csharp var api = new ApiResource("api1") { ApiSecrets = { new Secret("secret".Sha256()) }, Scopes = { "read", "write" } }; ``` ### Decision Matrix: JWT vs Reference Tokens | Criterion | JWT | Reference | | -------------------- | ----------------------------------- | ------------------------------------ | | Revocability | No (expires naturally) | Yes (immediate, delete from store) | | API call to validate | No (self-contained) | Yes (introspection endpoint) | | Network dependency | None at validation time | Requires IdentityServer availability | | Token size | Larger (contains all claims) | Small (just a handle) | | Performance at scale | Better (no server call) | Introspection adds latency | | Best for | High-throughput APIs, microservices | Sensitive APIs needing revocation | ### Token Revocation (RFC 7009) Revocation via the `/connect/revocation` endpoint applies **only to reference access tokens and refresh tokens** — the tokens that are persisted in the operational (persisted grant) store. A JWT access token is stateless and is **not** stored server-side by default, so there is no revocation state to deactivate: a JWT simply remains valid until its `exp`. If you need to invalidate access tokens immediately (logout, compromise, entitlement change), issue **reference** tokens (`AccessTokenType.Reference`). ### Controlling Token Format Per Client ```csharp // Set on the Client model client.AccessTokenType = AccessTokenType.Jwt; // default client.AccessTokenType = AccessTokenType.Reference; // reference tokens ``` ## Refresh Tokens Refresh tokens allow clients to obtain new access tokens without user interaction. They are supported for authorization code, hybrid, and resource owner password credential flows. ### Requesting Refresh Tokens The client must: 1. Have `AllowOfflineAccess = true` on its configuration 2. Request the `offline_access` scope in the authorize request ``` POST /connect/token Content-Type: application/x-www-form-urlencoded client_id=client& client_secret=secret& grant_type=refresh_token& refresh_token=hdh922 ``` Using Duende.IdentityModel: ```csharp using Duende.IdentityModel.Client; var client = new HttpClient(); var response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest { Address = TokenEndpoint, ClientId = "client", ClientSecret = "secret", RefreshToken = "..." }); ``` ### Refresh Token Lifetime Settings | Setting | Description | Recommendation | | ------------------------------ | -------------------------------------------------------- | ----------------------------------------------------- | | `AbsoluteRefreshTokenLifetime` | Maximum lifetime regardless of activity (seconds) | Set based on security policy (e.g., 30 days) | | `SlidingRefreshTokenLifetime` | Extends token life on each use, up to the absolute limit | Use for "remember me" scenarios (e.g., 1 day sliding) | | `RefreshTokenExpiration` | `Absolute` or `Sliding` | Use `Sliding` with a reasonable absolute cap | ### Rotation (OneTime vs ReUse) Configured via `RefreshTokenUsage` on the client: | Mode | Behavior | Trade-offs | | ---------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------- | | `ReUse` (default since v7.0) | Same refresh token is reused across requests | Robust to network failures, lower DB pressure | | `OneTime` | New refresh token issued on each use; old one consumed | Limited security benefit, risk of losing token on network failure | **Why `ReUse` is the default**: Rotating tokens on every use has limited security benefits regardless of client type. Reusable tokens are robust to network failures — if a one-time-use token is used but the response is lost, the client cannot recover without forcing a new login. Reusable tokens also have better performance since they avoid extra writes to the persisted grant store. ### Accepting Consumed Tokens (Network Failure Resilience) To make one-time-use tokens more resilient, subclass `DefaultRefreshTokenService` and override `AcceptConsumedTokenAsync`: ```csharp public class ResilientRefreshTokenService : DefaultRefreshTokenService { protected override Task AcceptConsumedTokenAsync(RefreshToken refreshToken) { // Allow consumed tokens for a short grace period var consumedAt = refreshToken.ConsumedTime ?? DateTime.UtcNow; if (DateTime.UtcNow - consumedAt < TimeSpan.FromSeconds(30)) { return Task.FromResult(true); } return Task.FromResult(false); } } ``` Register it: ```csharp builder.Services.TryAddTransient(); ``` **Important**: For this to work, `PersistentGrantOptions.DeleteOneTimeOnlyRefreshTokensOnUse` must be `false` so consumed tokens are marked rather than deleted. ### Replay Detection If a consumed refresh token is reused, it could indicate a replay attack. You can extend `AcceptConsumedTokenAsync` to revoke all access for the user/client: - Delete all refresh tokens for the user/client - Revoke reference access tokens - End the user's server-side session - Send back-channel logout notifications - Alert the user **Caution**: This is disruptive and can produce false positives from network failures or client bugs. ### Token Cleanup Configuration ```csharp builder.Services.AddIdentityServer() .AddOperationalStore(options => { options.EnableTokenCleanup = true; options.TokenCleanupInterval = 3600; // seconds (default: 1 hour) options.RemoveConsumedTokens = true; // also clean consumed tokens options.ConsumedTokenCleanupDelay = 300; // wait 5 min after consumption }); ``` ## Token Exchange (RFC 8693) Token exchange allows translating between token types. Common use cases: impersonation, delegation, SAML-to-JWT conversion. ### Implementing Token Exchange Implement `IExtensionGrantValidator`: ```csharp public class TokenExchangeGrantValidator : IExtensionGrantValidator { private readonly ITokenValidator _validator; public TokenExchangeGrantValidator(ITokenValidator validator) { _validator = validator; } public string GrantType => OidcConstants.GrantTypes.TokenExchange; public async Task ValidateAsync(ExtensionGrantValidationContext context) { context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest); var customResponse = new Dictionary { { OidcConstants.TokenResponse.IssuedTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken } }; var subjectToken = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectToken); var subjectTokenType = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectTokenType); if (string.IsNullOrWhiteSpace(subjectToken)) return; if (!string.Equals(subjectTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken)) return; var validationResult = await _validator.ValidateAccessTokenAsync(subjectToken); if (validationResult.IsError) return; var sub = validationResult.Claims.First(c => c.Type == JwtClaimTypes.Subject).Value; var clientId = validationResult.Claims.First(c => c.Type == JwtClaimTypes.ClientId).Value; // Impersonation: set client_id to the original context.Request.ClientId = clientId; context.Result = new GrantValidationResult( subject: sub, authenticationMethod: GrantType, customResponse: customResponse); } } ``` Register and configure: ```csharp // Program.cs idsvrBuilder.AddExtensionGrantValidator(); // Client configuration client.AllowedGrantTypes = { OidcConstants.GrantTypes.TokenExchange }; ``` ### Impersonation vs Delegation | Pattern | `client_id` in new token | `act` claim | Use case | | ------------- | ------------------------- | ---------------------------------- | --------------------------------------------- | | Impersonation | Original front-end client | Not present | API1 calls API2 "as if" it were the front-end | | Delegation | Original front-end client | Contains `{ "client_id": "api1" }` | API2 sees the full call chain | **Delegation** adds an `act` claim to preserve the call chain: ```csharp context.Request.ClientId = clientId; var actor = new { client_id = context.Request.Client.ClientId }; var actClaim = new Claim(JwtClaimTypes.Actor, JsonSerializer.Serialize(actor), IdentityServerConstants.ClaimValueTypes.Json); context.Result = new GrantValidationResult( subject: sub, authenticationMethod: GrantType, claims: new[] { actClaim }, customResponse: customResponse); ``` To emit the `act` claim in tokens, your profile service must handle it: ```csharp public class ProfileService : IProfileService { public async Task GetProfileDataAsync(ProfileDataRequestContext context) { if (context.Subject.GetAuthenticationMethod() == OidcConstants.GrantTypes.TokenExchange) { var act = context.Subject.FindFirst(JwtClaimTypes.Actor); if (act != null) { context.IssuedClaims.Add(act); } } } } ``` ### Sensitive Parameter Filtering Extension grant input parameters are logged by default. Filter sensitive values: ```csharp builder.Services.AddIdentityServer(options => { options.Logging.TokenRequestSensitiveValuesFilter.Add("custom_secret_param"); }); ``` ## Claims Customization with IProfileService The profile service controls which claims are emitted in identity tokens, access tokens, and userinfo responses. ### Strategies | Strategy | When to use | | --------------------------------------- | --------------------------------------------------------------- | | `context.AddRequestedClaims(claims)` | Respects scopes/resources requested by client; supports consent | | `context.IssuedClaims.AddRange(claims)` | Always emit claims regardless of request | | Custom logic per user/client | Conditional claims based on identity | ### Recommended Pattern Extend `DefaultProfileService` and use `AddRequestedClaims`: ```csharp public class SampleProfileService : DefaultProfileService { public override async Task GetProfileDataAsync(ProfileDataRequestContext context) { var claims = await GetClaimsFromDatabaseAsync(context.Subject); context.AddRequestedClaims(claims); } } ``` ### Client Claims Client claims are defined per-client and emitted in access tokens (prefixed with `client_` by default): ```csharp var client = new Client { ClientId = "client", Claims = { new ClientClaim("customer_id", "123") } // Emitted as "client_customer_id" in access tokens }; ``` Change or remove the prefix: ```csharp client.ClientClaimsPrefix = ""; // no prefix ``` By default, client claims are only sent in client credentials flow. To include them in all flows: ```csharp client.AlwaysSendClientClaims = true; ``` ### Claim Serialization Claims are serialized based on `ClaimValueType`: - No type specified → string - `ClaimValueTypes.Integer`, `Integer32`, `Integer64`, `Double`, `Boolean` → parsed as corresponding type - `IdentityServerConstants.ClaimValueTypes.Json` → serialized as JSON ## Issuing Internal Tokens When extensibility code needs to call other APIs, use `IIdentityServerTools` instead of the protocol endpoints: ```csharp app.MapGet("/myAction", async (IIdentityServerTools tools) => { var token = await tools.IssueClientJwtAsync( clientId: "client_id", lifetime: 3600, audiences: new[] { "backend.api" }); // Use token to call backend API }); ``` ## Dynamic Issuer (Multi-Issuer) By **default**, a single IdentityServer derives the `iss` claim (and the discovery issuer) from the origin of the incoming request. The same deployment can therefore serve multiple hosts/domains and return a different `iss` for each — no extra configuration required. ```csharp // Requests to https://a.example.com → iss = "https://a.example.com" // Requests to https://b.example.com → iss = "https://b.example.com" ``` Setting a fixed issuer **disables** this behavior — every token then carries the configured value regardless of host: ```csharp builder.Services.AddIdentityServer(options => { // Pins iss to a single value; multi-issuer is turned off options.IssuerUri = "https://identity.example.com"; }); ``` > **Multi-issuer is not multi-tenancy.** Returning a per-host `iss` (RFC 7519 §4.1.1) does not isolate users, grants, keys, or any other data per domain. Tenant isolation remains the implementer's responsibility. ## Token Lifetime Best Practices | Token | Recommended Lifetime | Rationale | | ------------------------ | ----------------------------------------- | ------------------------------------------------ | | Identity Token | 5 minutes (default: 300s) | Only used once during authentication | | JWT Access Token | 5-15 minutes (default: 3600s = 1 hour) | Short-lived; cannot be revoked | | Reference Access Token | 5-60 minutes | Can be revoked, so slightly longer is acceptable | | Refresh Token (absolute) | Hours to days depending on security needs | Balance UX vs risk | | Refresh Token (sliding) | Shorter than absolute (e.g., 1 hour) | Auto-expire unused tokens | ## Common Anti-Patterns - ❌ Sending identity tokens to APIs for authorization — they are for the client only - ✅ Use access tokens (JWT or reference) for API authorization - ❌ Using very long-lived JWT access tokens (hours/days) with no revocation mechanism - ✅ Keep JWT lifetimes short (5-15 min) and use refresh tokens for longevity - ❌ Enabling `OneTime` refresh token rotation without considering network failure scenarios - ✅ Use `ReUse` (default) or implement `AcceptConsumedTokenAsync` with a grace period - ❌ Putting all user claims directly into access tokens, creating bloated JWTs - ✅ Use `AddRequestedClaims` to emit only claims requested by scopes; use the userinfo endpoint for additional claims - ❌ Parsing the `returnUrl` manually instead of using `GetAuthorizationContextAsync` - ✅ Always use the interaction service to extract authorization context - ❌ Forgetting to set `AllowOfflineAccess = true` on the client and then wondering why no refresh token is issued - ✅ Configure both the client property and request the `offline_access` scope ## Common Pitfalls 1. **Reference tokens require introspection**: APIs consuming reference tokens must call the introspection endpoint. Without a configured `ApiSecret` on the `ApiResource`, introspection will fail with `401`. 2. **Refresh token cleanup**: Enable `EnableTokenCleanup` in the operational store options. Without it, expired and consumed tokens accumulate indefinitely. 3. **Token exchange client configuration**: The client performing token exchange must have `AllowedGrantTypes` set to `urn:ietf:params:oauth:grant-type:token-exchange` (use `OidcConstants.GrantTypes.TokenExchange`). 4. **Profile service `Subject` differs by caller**: When called for userinfo requests, the `Subject` property contains claims from the access token, not the authentication session. Check `context.Caller` to determine the source. 5. **Client claims prefix collision**: Client claims are prefixed with `client_` by default. Adjust `ClientClaimsPrefix` if this collides with existing user claim types.