---
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<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.cs
idsvrBuilder.AddExtensionGrantValidator<TokenExchangeGrantValidator>();

// 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.
