← Duende SkillsCONTENT HISTORY

Update to Duende Skills

Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full 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