Table of Contents

Token exchange (RFC 8693)

A back-end service holding an access token for a user can exchange it for one of its own, for a different audience — RFC 8693. The recorded flow shows the requests and responses: impersonation, an acting service getting a token of its own, and delegation. The code on this page is included from samples/Protocols.Grants, which a test boots and exchanges a user's token against, with and without an actor.

Impersonation

Register the client for the grant and nothing else is needed — it ships wired:

// A back-end service that exchanges a user's token for one of its own, for the shipping API.
new Client
{
    ClientId = "orders-service",
    ClientSecrets = { new Secret("orders-service-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.TokenExchange },
    AllowedScopes = { "shipping" },
},

The client sends grant_type=urn:ietf:params:oauth:grant-type:token-exchange, the user's token as subject_token, subject_token_type=urn:ietf:params:oauth:token-type:access_token and the scopes it wants; the response's issued_token_type says what came back.

Without this, a service that needs to call a further API on a user's behalf has one option: forward the token it was given. That hands the downstream API a token minted for a different audience, carrying scopes it was never meant to see, and nothing in it saying who is really calling.

What the exchange does not do is widen anything. The requested scopes go through the exchanging client's own registration and the resource store, exactly as they do for every other grant — holding a user's token does not let a client ask for a scope it is not registered for. And only the subject crosses over: every other claim on the issued token is rebuilt for the exchanging client from its own registration and your IProfileService, so a claim the original audience was trusted with does not travel to one that was not. A subject token that names no user (a client_credentials token) is refused, invalid_grant — there is nobody to impersonate, and the token it would produce is one the client could have asked for directly.

Only an access token the token endpoint issued is exchanged (0.176.0, a security fix). The subject token — and the actor token, below — must validate as an access token, so an identity token or a back-channel logout token is refused (only an access token is an access token), and it must name its client (client_id) and its scopes (scope), as every token the token endpoint issues does. Either refused, invalid_grant. Until 0.176.0 an identity token a relying party had been given — which any relying party holds for each of its users — was exchanged for an access token to whatever the exchanging client may reach, as that user.

Security — who has to act. Nobody exchanging tokens this server's token endpoint issued. A host that exchanges a JWT it minted itself with IIdentityServerTools.IssueJwtAsync must give it client_id and scope claims (IssueClientJwtAsync writes both), or it is now refused.

A sender-constrained subject token cannot be exchanged (0.45.0). A token bound to a DPoP key or a certificate may only be used by its holder, and exchanging it is using it — but a request carries one DPoP header and this one's is already spent binding the token being issued, so there is no second proof to check the subject token against. Refused with invalid_grant, because the alternative is handing back an unbound token for another audience and losing the constraint entirely. See DPoP.

Delegation — saying which service acted

Impersonation says the user did something. Delegation says which service did it for them, which is what a downstream API needs in order to hold anyone accountable: an impersonation token is indistinguishable from the user working alone, so a log line, an audit entry and an authorization decision all name the wrong party.

Send actor_token alongside the subject token, and the issued token carries an act claim naming the service:

{
  "sub": "alice",
  "client_id": "orders-service",
  "act": { "client_id": "batch-runner" }
}

Chained delegation nests rather than replaces (RFC 8693 §4.1), so a token that has passed through two services records both:

"act": { "client_id": "batch-runner", "act": { "client_id": "scheduler" } }

It needs a policy, and there is no default

Something has to decide that this actor was allowed to act for this subject. RFC 8693 §4.4 calls the token-borne form of that may_act and leaves the determination to the authorization server — and this library will not guess, because who may act for whom is a fact about your organisation, not about OAuth. That is an IDelegationPolicy, given the client exchanging, the subject and the actor token's claims (DelegationContext):

/// <summary>
/// Who may act for whom: an allow-list of the services each exchanging client may name as its actor. Anything not
/// listed is refused, including a service this policy has never heard of.
/// </summary>
public sealed class ActingServices : IDelegationPolicy
{
    private static readonly Dictionary<string, string[]> MayActThrough = new(StringComparer.Ordinal)
    {
        ["orders-service"] = ["batch-runner"],
    };

    /// <inheritdoc />
    public Task<bool> MayActAsync(DelegationContext context, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(context);

        // context.Client is the client exchanging, context.SubjectId the user acted for, and context.Actor the
        // validated actor token's claims.
        var actor = context.Actor.FindFirst(JwtClaimTypes.ClientId)?.Value;
        return Task.FromResult(
            actor is not null
            && MayActThrough.TryGetValue(context.Client.ClientId, out var actors)
            && actors.Contains(actor, StringComparer.Ordinal));
    }
}
// The extension grant: registering it is what makes its grant_type supported. Token exchange is registered by
// AddIdentityServer already; the delegation policy is what lets an actor_token through.
builder.Services.AddSingleton<SensorRegistry>();
builder.Services.AddTransient<IExtensionGrantValidator, SensorAttestationGrant>();
builder.Services.AddTransient<IDelegationPolicy, ActingServices>();

With no policy registered, actor_token is refused with invalid_request. That is the shipped default and it is the safe direction: a server that quietly issued a plain impersonation token instead would hand the caller something weaker than it asked for, under a name saying otherwise, and nothing downstream could tell.

Write the policy as an allow-list, as the sample does. One that refuses only what it recognises permits everything it has never heard of, which is the wrong direction for a decision ending in one service acting under another party's name.

New in 0.34.0. 0.33.0 shipped impersonation and refused actor_token outright rather than half-building this.

New in 0.33.0. It ships as a first-party extension grant, registered by default. If your host already registers its own IExtensionGrantValidator for this grant type, the two collide and UseIdentityServer() refuses to start — the same rule that has guarded validator collisions since 0.28.0. Remove yours, or keep it and take this one out of the container.

Fixed in 0.33.1: on 0.33.0 that collision also fired when nobody had registered anything — calling AddIdentityServer() twice, which every other registration in it tolerates, added a second copy of this validator and refused to boot on it. Upgrade if your host adds the server more than once.