Identityserver Token Lifecycle

作者 DuendeSoftwarefb32edc51982無授權條款9 個星標收錄於 2026年10月8日更新於 2026年10月8日儲存庫4 週前更新

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.

AI 產生的概覽

指導在 Duende IdentityServer 中實作權杖類型、更新權杖、權杖交換、宣告客製與生命週期。

功能
說明 Duende IdentityServer 如何簽發身分權杖、存取權杖與更新權杖,以及如何在 JWT 與參照權杖之間選擇。內容涵蓋更新權杖輪替、滑動到期、重播偵測、清理設定、透過 RFC 8693 擴充授權驗證器實作權杖交換、IProfileService 宣告客製、內部權杖簽發與生命週期建議。產出為設定與 C# 實作指引,以及決策表和反模式清單。
適用情境
在為 Duende IdentityServer 部署設定權杖格式、生命週期或更新權杖行為時使用。在實作模擬或委派的權杖交換,或客製權杖與 userinfo 回應中包含哪些宣告時使用。在檢視權杖相關設定以排查常見陷阱時使用。
執行需求
不需要指令碼或附帶資源,代理只需 SKILL.md 中的說明。程式碼範例以 .NET/C# 的 Duende IdentityServer 與 Duende.IdentityModel 為主,文中引用廠商文件連結,但執行本身不依賴它。

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 TypePurposeAudienceFormat
Identity TokenCommunicates authentication event to the clientClient application only (aud claim)Always JWT
Access TokenAuthorizes access to a protected resource (API)API / Resource ServerJWT or Reference
Refresh TokenObtains new access tokens without user interactionToken endpoint onlyOpaque 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 tokensclient.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

CriterionJWTReference
RevocabilityNo (expires naturally)Yes (immediate, delete from store)
API call to validateNo (self-contained)Yes (introspection endpoint)
Network dependencyNone at validation timeRequires IdentityServer availability
Token sizeLarger (contains all claims)Small (just a handle)
Performance at scaleBetter (no server call)Introspection adds latency
Best forHigh-throughput APIs, microservicesSensitive 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 modelclient.AccessTokenType = AccessTokenType.Jwt;       // defaultclient.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/tokenContent-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

SettingDescriptionRecommendation
AbsoluteRefreshTokenLifetimeMaximum lifetime regardless of activity (seconds)Set based on security policy (e.g., 30 days)
SlidingRefreshTokenLifetimeExtends token life on each use, up to the absolute limitUse for "remember me" scenarios (e.g., 1 day sliding)
RefreshTokenExpirationAbsolute or SlidingUse Sliding with a reasonable absolute cap

Rotation (OneTime vs ReUse)

Configured via RefreshTokenUsage on the client:

ModeBehaviorTrade-offs
ReUse (default since v7.0)Same refresh token is reused across requestsRobust to network failures, lower DB pressure
OneTimeNew refresh token issued on each use; old one consumedLimited 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<bool> 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<IRefreshTokenService, ResilientRefreshTokenService>();

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<string, object>        {            { 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.csidsvrBuilder.AddExtensionGrantValidator<TokenExchangeGrantValidator>();
// Client configurationclient.AllowedGrantTypes = { OidcConstants.GrantTypes.TokenExchange };

Impersonation vs Delegation

Patternclient_id in new tokenact claimUse case
ImpersonationOriginal front-end clientNot presentAPI1 calls API2 "as if" it were the front-end
DelegationOriginal front-end clientContains { "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

StrategyWhen 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/clientConditional 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

TokenRecommended LifetimeRationale
Identity Token5 minutes (default: 300s)Only used once during authentication
JWT Access Token5-15 minutes (default: 3600s = 1 hour)Short-lived; cannot be revoked
Reference Access Token5-60 minutesCan be revoked, so slightly longer is acceptable
Refresh Token (absolute)Hours to days depending on security needsBalance 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.

來源與署名

來源:DuendeSoftware/duende-skills位於skills/identityserver-token-lifecycle提交fb32edc

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架