← Duende SkillsCONTENT HISTORYWHAT CHANGED · RULE-BASED ANALYSIS
Update to Duende Skills
Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0
Collection source: not recorded for this historical snapshot.
First saved snapshot
No earlier snapshot is available to establish a change.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"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.",
"included_files": [],
"skill_md_contents": "---\nname: identityserver-token-lifecycle\ndescription: \"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.\"\ninvocable: false\n---\n\n# IdentityServer Token Types, Refresh Tokens, and Token Exchange\n\n## When to Use This Skill\n\n- Choosing between JWT and reference access tokens for a client\n- Configuring refresh token rotation, sliding expiration, or replay detection\n- Implementing token exchange (RFC 8693) for impersonation or delegation\n- Building an extension grant validator (`IExtensionGrantValidator`)\n- Customizing which claims appear in identity tokens, access tokens, or userinfo responses via `IProfileService`\n- Setting token lifetime policies for access tokens and refresh tokens\n- Issuing internal tokens from extensibility code via `IIdentityServerTools`\n- Understanding identity tokens vs access tokens and their intended audiences\n\nDocs: https://docs.duendesoftware.com/identityserver/tokens\n\n## Token Types Overview\n\nDuende IdentityServer issues three primary token types:\n\n| Token Type | Purpose | Audience | Format |\n| -------------- | -------------------------------------------------- | ------------------------------------- | ---------------- |\n| Identity Token | Communicates authentication event to the client | Client application only (`aud` claim) | Always JWT |\n| Access Token | Authorizes access to a protected resource (API) | API / Resource Server | JWT or Reference |\n| Refresh Token | Obtains new access tokens without user interaction | Token endpoint only | Opaque handle |\n\n### Key Principles\n\n- Identity tokens are **solely for the client application** that initiated the authentication. Never send an identity token to an API.\n- Access tokens are for APIs. They contain client ID, scopes, expiration, and optionally user claims.\n- Refresh tokens enable long-lived API access by allowing the client to request new access tokens silently.\n\n## Identity Tokens\n\nIdentity tokens are JWTs that describe \"what happened at the token service\". They contain:\n\n- `iss` — the issuer (your IdentityServer URL)\n- `sub` — the authenticated user's unique identifier\n- `aud` — the client that requested authentication\n- `auth_time` — when the user authenticated\n- `amr` — authentication method (e.g., `pwd`)\n- `idp` — identity provider used (e.g., `local`)\n- `sid` — the session ID\n- `nonce` — ensures the token is consumed only once at the client\n\n```json\n{\n \"iss\": \"https://localhost:5001\",\n \"nbf\": 1609932802,\n \"iat\": 1609932802,\n \"exp\": 1609933102,\n \"aud\": \"web_app\",\n \"amr\": [\"pwd\"],\n \"nonce\": \"63745529591...I3ZTIyOTZmZTNj\",\n \"sid\": \"F6E6F2EDE86EB8731EF609A4FE40ED89\",\n \"auth_time\": 1609932794,\n \"idp\": \"local\",\n \"sub\": \"88421113\",\n \"name\": \"Bob\"\n}\n```\n\n## Access Tokens: JWT vs Reference\n\n### JWT Access Tokens\n\nAll 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`.\n\n```json\n{\n \"iss\": \"https://localhost:5001\",\n \"exp\": 1609936401,\n \"aud\": \"urn:resource1\",\n \"scope\": \"openid resource1.scope1 offline_access\",\n \"client_id\": \"web_app\",\n \"sub\": \"88421113\",\n \"jti\": \"2C56A356A306E64AFC7D2C6399E23A17\"\n}\n```\n\n### Reference Access Tokens\n\nReference 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.\n\n```csharp\n// Configure a client to use reference tokens\nclient.AccessTokenType = AccessTokenType.Reference;\n```\n\nThe API consuming reference tokens must have a secret configured on the `ApiResource`:\n\n```csharp\nvar api = new ApiResource(\"api1\")\n{\n ApiSecrets = { new Secret(\"secret\".Sha256()) },\n Scopes = { \"read\", \"write\" }\n};\n```\n\n### Decision Matrix: JWT vs Reference Tokens\n\n| Criterion | JWT | Reference |\n| -------------------- | ----------------------------------- | ------------------------------------ |\n| Revocability | No (expires naturally) | Yes (immediate, delete from store) |\n| API call to validate | No (self-contained) | Yes (introspection endpoint) |\n| Network dependency | None at validation time | Requires IdentityServer availability |\n| Token size | Larger (contains all claims) | Small (just a handle) |\n| Performance at scale | Better (no server call) | Introspection adds latency |\n| Best for | High-throughput APIs, microservices | Sensitive APIs needing revocation |\n\n### Token Revocation (RFC 7009)\n\nRevocation 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`).\n\n### Controlling Token Format Per Client\n\n```csharp\n// Set on the Client model\nclient.AccessTokenType = AccessTokenType.Jwt; // default\nclient.AccessTokenType = AccessTokenType.Reference; // reference tokens\n```\n\n## Refresh Tokens\n\nRefresh tokens allow clients to obtain new access tokens without user interaction. They are supported for authorization code, hybrid, and resource owner password credential flows.\n\n### Requesting Refresh Tokens\n\nThe client must:\n\n1. Have `AllowOfflineAccess = true` on its configuration\n2. Request the `offline_access` scope in the authorize request\n\n```\nPOST /connect/token\nContent-Type: application/x-www-form-urlencoded\n\n client_id=client&\n client_secret=secret&\n grant_type=refresh_token&\n refresh_token=hdh922\n```\n\nUsing Duende.IdentityModel:\n\n```csharp\nusing Duende.IdentityModel.Client;\n\nvar client = new HttpClient();\n\nvar response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest\n{\n Address = TokenEndpoint,\n ClientId = \"client\",\n ClientSecret = \"secret\",\n RefreshToken = \"...\"\n});\n```\n\n### Refresh Token Lifetime Settings\n\n| Setting | Description | Recommendation |\n| ------------------------------ | -------------------------------------------------------- | ----------------------------------------------------- |\n| `AbsoluteRefreshTokenLifetime` | Maximum lifetime regardless of activity (seconds) | Set based on security policy (e.g., 30 days) |\n| `SlidingRefreshTokenLifetime` | Extends token life on each use, up to the absolute limit | Use for \"remember me\" scenarios (e.g., 1 day sliding) |\n| `RefreshTokenExpiration` | `Absolute` or `Sliding` | Use `Sliding` with a reasonable absolute cap |\n\n### Rotation (OneTime vs ReUse)\n\nConfigured via `RefreshTokenUsage` on the client:\n\n| Mode | Behavior | Trade-offs |\n| ---------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------- |\n| `ReUse` (default since v7.0) | Same refresh token is reused across requests | Robust to network failures, lower DB pressure |\n| `OneTime` | New refresh token issued on each use; old one consumed | Limited security benefit, risk of losing token on network failure |\n\n**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.\n\n### Accepting Consumed Tokens (Network Failure Resilience)\n\nTo make one-time-use tokens more resilient, subclass `DefaultRefreshTokenService` and override `AcceptConsumedTokenAsync`:\n\n```csharp\npublic class ResilientRefreshTokenService : DefaultRefreshTokenService\n{\n protected override Task<bool> AcceptConsumedTokenAsync(RefreshToken refreshToken)\n {\n // Allow consumed tokens for a short grace period\n var consumedAt = refreshToken.ConsumedTime ?? DateTime.UtcNow;\n if (DateTime.UtcNow - consumedAt < TimeSpan.FromSeconds(30))\n {\n return Task.FromResult(true);\n }\n return Task.FromResult(false);\n }\n}\n```\n\nRegister it:\n\n```csharp\nbuilder.Services.TryAddTransient<IRefreshTokenService, ResilientRefreshTokenService>();\n```\n\n**Important**: For this to work, `PersistentGrantOptions.DeleteOneTimeOnlyRefreshTokensOnUse` must be `false` so consumed tokens are marked rather than deleted.\n\n### Replay Detection\n\nIf a consumed refresh token is reused, it could indicate a replay attack. You can extend `AcceptConsumedTokenAsync` to revoke all access for the user/client:\n\n- Delete all refresh tokens for the user/client\n- Revoke reference access tokens\n- End the user's server-side session\n- Send back-channel logout notifications\n- Alert the user\n\n**Caution**: This is disruptive and can produce false positives from network failures or client bugs.\n\n### Token Cleanup Configuration\n\n```csharp\nbuilder.Services.AddIdentityServer()\n .AddOperationalStore(options =>\n {\n options.EnableTokenCleanup = true;\n options.TokenCleanupInterval = 3600; // seconds (default: 1 hour)\n options.RemoveConsumedTokens = true; // also clean consumed tokens\n options.ConsumedTokenCleanupDelay = 300; // wait 5 min after consumption\n });\n```\n\n## Token Exchange (RFC 8693)\n\nToken exchange allows translating between token types. Common use cases: impersonation, delegation, SAML-to-JWT conversion.\n\n### Implementing Token Exchange\n\nImplement `IExtensionGrantValidator`:\n\n```csharp\npublic class TokenExchangeGrantValidator : IExtensionGrantValidator\n{\n private readonly ITokenValidator _validator;\n\n public TokenExchangeGrantValidator(ITokenValidator validator)\n {\n _validator = validator;\n }\n\n public string GrantType => OidcConstants.GrantTypes.TokenExchange;\n\n public async Task ValidateAsync(ExtensionGrantValidationContext context)\n {\n context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);\n\n var customResponse = new Dictionary<string, object>\n {\n { OidcConstants.TokenResponse.IssuedTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken }\n };\n\n var subjectToken = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectToken);\n var subjectTokenType = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectTokenType);\n\n if (string.IsNullOrWhiteSpace(subjectToken)) return;\n\n if (!string.Equals(subjectTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken)) return;\n\n var validationResult = await _validator.ValidateAccessTokenAsync(subjectToken);\n if (validationResult.IsError) return;\n\n var sub = validationResult.Claims.First(c => c.Type == JwtClaimTypes.Subject).Value;\n var clientId = validationResult.Claims.First(c => c.Type == JwtClaimTypes.ClientId).Value;\n\n // Impersonation: set client_id to the original\n context.Request.ClientId = clientId;\n context.Result = new GrantValidationResult(\n subject: sub,\n authenticationMethod: GrantType,\n customResponse: customResponse);\n }\n}\n```\n\nRegister and configure:\n\n```csharp\n// Program.cs\nidsvrBuilder.AddExtensionGrantValidator<TokenExchangeGrantValidator>();\n\n// Client configuration\nclient.AllowedGrantTypes = { OidcConstants.GrantTypes.TokenExchange };\n```\n\n### Impersonation vs Delegation\n\n| Pattern | `client_id` in new token | `act` claim | Use case |\n| ------------- | ------------------------- | ---------------------------------- | --------------------------------------------- |\n| Impersonation | Original front-end client | Not present | API1 calls API2 \"as if\" it were the front-end |\n| Delegation | Original front-end client | Contains `{ \"client_id\": \"api1\" }` | API2 sees the full call chain |\n\n**Delegation** adds an `act` claim to preserve the call chain:\n\n```csharp\ncontext.Request.ClientId = clientId;\n\nvar actor = new { client_id = context.Request.Client.ClientId };\nvar actClaim = new Claim(JwtClaimTypes.Actor,\n JsonSerializer.Serialize(actor),\n IdentityServerConstants.ClaimValueTypes.Json);\n\ncontext.Result = new GrantValidationResult(\n subject: sub,\n authenticationMethod: GrantType,\n claims: new[] { actClaim },\n customResponse: customResponse);\n```\n\nTo emit the `act` claim in tokens, your profile service must handle it:\n\n```csharp\npublic class ProfileService : IProfileService\n{\n public async Task GetProfileDataAsync(ProfileDataRequestContext context)\n {\n if (context.Subject.GetAuthenticationMethod() == OidcConstants.GrantTypes.TokenExchange)\n {\n var act = context.Subject.FindFirst(JwtClaimTypes.Actor);\n if (act != null)\n {\n context.IssuedClaims.Add(act);\n }\n }\n }\n}\n```\n\n### Sensitive Parameter Filtering\n\nExtension grant input parameters are logged by default. Filter sensitive values:\n\n```csharp\nbuilder.Services.AddIdentityServer(options =>\n{\n options.Logging.TokenRequestSensitiveValuesFilter.Add(\"custom_secret_param\");\n});\n```\n\n## Claims Customization with IProfileService\n\nThe profile service controls which claims are emitted in identity tokens, access tokens, and userinfo responses.\n\n### Strategies\n\n| Strategy | When to use |\n| --------------------------------------- | --------------------------------------------------------------- |\n| `context.AddRequestedClaims(claims)` | Respects scopes/resources requested by client; supports consent |\n| `context.IssuedClaims.AddRange(claims)` | Always emit claims regardless of request |\n| Custom logic per user/client | Conditional claims based on identity |\n\n### Recommended Pattern\n\nExtend `DefaultProfileService` and use `AddRequestedClaims`:\n\n```csharp\npublic class SampleProfileService : DefaultProfileService\n{\n public override async Task GetProfileDataAsync(ProfileDataRequestContext context)\n {\n var claims = await GetClaimsFromDatabaseAsync(context.Subject);\n context.AddRequestedClaims(claims);\n }\n}\n```\n\n### Client Claims\n\nClient claims are defined per-client and emitted in access tokens (prefixed with `client_` by default):\n\n```csharp\nvar client = new Client\n{\n ClientId = \"client\",\n Claims = { new ClientClaim(\"customer_id\", \"123\") }\n // Emitted as \"client_customer_id\" in access tokens\n};\n```\n\nChange or remove the prefix:\n\n```csharp\nclient.ClientClaimsPrefix = \"\"; // no prefix\n```\n\nBy default, client claims are only sent in client credentials flow. To include them in all flows:\n\n```csharp\nclient.AlwaysSendClientClaims = true;\n```\n\n### Claim Serialization\n\nClaims are serialized based on `ClaimValueType`:\n\n- No type specified → string\n- `ClaimValueTypes.Integer`, `Integer32`, `Integer64`, `Double`, `Boolean` → parsed as corresponding type\n- `IdentityServerConstants.ClaimValueTypes.Json` → serialized as JSON\n\n## Issuing Internal Tokens\n\nWhen extensibility code needs to call other APIs, use `IIdentityServerTools` instead of the protocol endpoints:\n\n```csharp\napp.MapGet(\"/myAction\", async (IIdentityServerTools tools) =>\n{\n var token = await tools.IssueClientJwtAsync(\n clientId: \"client_id\",\n lifetime: 3600,\n audiences: new[] { \"backend.api\" });\n\n // Use token to call backend API\n});\n```\n\n## Dynamic Issuer (Multi-Issuer)\n\nBy **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.\n\n```csharp\n// Requests to https://a.example.com → iss = \"https://a.example.com\"\n// Requests to https://b.example.com → iss = \"https://b.example.com\"\n```\n\nSetting a fixed issuer **disables** this behavior — every token then carries the configured value regardless of host:\n\n```csharp\nbuilder.Services.AddIdentityServer(options =>\n{\n // Pins iss to a single value; multi-issuer is turned off\n options.IssuerUri = \"https://identity.example.com\";\n});\n```\n\n> **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.\n\n## Token Lifetime Best Practices\n\n| Token | Recommended Lifetime | Rationale |\n| ------------------------ | ----------------------------------------- | ------------------------------------------------ |\n| Identity Token | 5 minutes (default: 300s) | Only used once during authentication |\n| JWT Access Token | 5-15 minutes (default: 3600s = 1 hour) | Short-lived; cannot be revoked |\n| Reference Access Token | 5-60 minutes | Can be revoked, so slightly longer is acceptable |\n| Refresh Token (absolute) | Hours to days depending on security needs | Balance UX vs risk |\n| Refresh Token (sliding) | Shorter than absolute (e.g., 1 hour) | Auto-expire unused tokens |\n\n## Common Anti-Patterns\n\n- ❌ Sending identity tokens to APIs for authorization — they are for the client only\n- ✅ Use access tokens (JWT or reference) for API authorization\n\n- ❌ Using very long-lived JWT access tokens (hours/days) with no revocation mechanism\n- ✅ Keep JWT lifetimes short (5-15 min) and use refresh tokens for longevity\n\n- ❌ Enabling `OneTime` refresh token rotation without considering network failure scenarios\n- ✅ Use `ReUse` (default) or implement `AcceptConsumedTokenAsync` with a grace period\n\n- ❌ Putting all user claims directly into access tokens, creating bloated JWTs\n- ✅ Use `AddRequestedClaims` to emit only claims requested by scopes; use the userinfo endpoint for additional claims\n\n- ❌ Parsing the `returnUrl` manually instead of using `GetAuthorizationContextAsync`\n- ✅ Always use the interaction service to extract authorization context\n\n- ❌ Forgetting to set `AllowOfflineAccess = true` on the client and then wondering why no refresh token is issued\n- ✅ Configure both the client property and request the `offline_access` scope\n\n## Common Pitfalls\n\n1. **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`.\n\n2. **Refresh token cleanup**: Enable `EnableTokenCleanup` in the operational store options. Without it, expired and consumed tokens accumulate indefinitely.\n\n3. **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`).\n\n4. **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.\n\n5. **Client claims prefix collision**: Client claims are prefixed with `client_` by default. Adjust `ClientClaimsPrefix` if this collides with existing user claim types.\n"
}SHA-256: bec614dd114525515c10feebe7140cc49130b382ae585e0d18f844587fa68ff3