Token Exchange — The OAuth Flow Nobody Talks About (Until They Need It)
    Security

    Token Exchange — The OAuth Flow Nobody Talks About (Until They Need It)

    RFC 8693 solves a specific problem that every microservice architecture eventually hits: how to propagate identity across service boundaries without sharing raw tokens.

    Reverse PolarityFebruary 27, 202510 min read

    RFC 8693 landed in January 2020 after years of drafting, and it still gets ignored. Not because it's obscure — if you've built a service mesh, a backend-for-frontend, or any multi-tier architecture where an upstream service needs to call a downstream one on behalf of a user, you've either reinvented parts of it or punted on the problem entirely. Token exchange is the standardised answer to the question: "a service holds a token that proves who the user is, but it needs a different token to call a backend — how should it get one?"

    The grant type is urn:ietf:params:oauth:grant-type:token-exchange, which is an extension grant against the standard token endpoint. No new endpoint, no new infrastructure beyond what you already have for OAuth 2.0. What the RFC actually specifies is more nuanced than that summary suggests, and the nuances are where real implementations fall down.

    Diagram — OAuth 2.0 Token Exchange (RFC 8693)
    Diagram

    1. The request shape — what is REQUIRED and what is OPTIONAL

    The token exchange request is a standard application/x-www-form-urlencoded POST to the token endpoint. The mandatory fields are fewer than most people expect:

    POST /token HTTP/1.1
    Host: as.example.com
    Authorization: Basic <client_credentials>
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
    &subject_token=<the_token_you_have>
    &subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
    

    That's the minimal valid request. grant_type, subject_token, and subject_token_type are REQUIRED. Everything else — resource, audience, scope, requested_token_type, actor_token, actor_token_type — is OPTIONAL.

    The response fields tell a similar story. access_token, issued_token_type, and token_type are REQUIRED. expires_in is RECOMMENDED. scope is OPTIONAL unless the issued scope differs from what was requested, in which case it is REQUIRED. refresh_token is OPTIONAL and will typically not be issued — the spec is fairly clear that exchanging one temporary credential for another doesn't naturally lead to a refresh token, though deployments with offline access scenarios can issue one if they document when to expect it.

    One subtle point about the response: the access_token field carries the issued token regardless of what type it actually is. The spec notes this is for historical reasons and because it allows the protocol to reuse the existing OAuth 2.0 response structure. The issued_token_type is what actually tells you what you got back. If the server issued a SAML 2.0 assertion, it comes back in access_token with issued_token_type set to urn:ietf:params:oauth:token-type:saml2. The token_type field then uses the special value N_A to indicate that the normal Bearer/MAC/DPoP semantics don't apply.


    2. Impersonation vs. delegation — the distinction that shapes your JWT claims

    These two modes look similar from the outside but have very different security semantics and produce structurally different tokens.

    Impersonation: service A presents a token identifying user B, and the authorization server issues a new token where the sub is still B. From the perspective of any downstream service, it looks like user B is making the request directly. The acting party A is invisible in the issued token. This is achieved simply by sending a subject_token with no actor_token.

    Delegation: service A presents both a subject_token identifying user B and an actor_token identifying itself. The authorization server issues a composite token: sub is B, but the act claim identifies A as the current acting party. The downstream service can see both the original subject and who is currently wielding the token. This requires an actor_token in the request.

    The RFC is explicit: delegation is impossible with only a subject_token and no actor_token. If you send just the user's token, you get impersonation semantics whether you intended them or not.

    In Go, constructing the request for a delegation exchange looks roughly like this:

    type TokenExchangeRequest struct {
        GrantType        string
        SubjectToken     string
        SubjectTokenType string
        ActorToken       string // empty for impersonation
        ActorTokenType   string // empty for impersonation
        Audience         string
        Scope            string
        RequestedTokenType string
    }
    
    func buildExchangeForm(r TokenExchangeRequest) url.Values {
        v := url.Values{}
        v.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
        v.Set("subject_token", r.SubjectToken)
        v.Set("subject_token_type", r.SubjectTokenType)
        if r.ActorToken != "" {
            v.Set("actor_token", r.ActorToken)
            v.Set("actor_token_type", r.ActorTokenType)
        }
        if r.Audience != "" {
            v.Set("audience", r.Audience)
        }
        if r.Scope != "" {
            v.Set("scope", r.Scope)
        }
        if r.RequestedTokenType != "" {
            v.Set("requested_token_type", r.RequestedTokenType)
        }
        return v
    }
    

    Note the conditional inclusion of actor_token_type: the spec says it is REQUIRED when actor_token is present, and MUST NOT be included otherwise. This pair has a hard interdependency that parsers should enforce.


    3. The act claim — what it encodes and how access control must use it

    When the authorization server issues a delegation token, it encodes the acting party in the act JWT claim. The value is a JSON object containing claims that identify the actor — most commonly sub and iss together to uniquely identify them across issuers.

    {
      "aud": "https://payments.example.com",
      "iss": "https://as.example.com",
      "exp": 1443904177,
      "sub": "user@example.com",
      "act": {
        "sub": "https://orders-service.example.com"
      }
    }
    

    The RFC specifies something that's easy to miss in access control logic: exp, nbf, and aud are not meaningful inside the act object and must not be used there. The act claim pertains only to actor identity, not token validity. Validity is governed entirely by the top-level claims.

    The chain of delegation case is where the act claim gets interesting. If orders-service held a token, exchanged it with billing-service's actor_token, and that exchange produced a new token for calling the ledger service, you get nested act claims:

    {
      "sub": "user@example.com",
      "act": {
        "sub": "https://billing-service.example.com",
        "act": {
          "sub": "https://orders-service.example.com"
        }
      }
    }
    

    The outermost act is the current actor. Nested act claims are a history trail. The RFC is unambiguous about access control: consumers of a token MUST only consider the top-level claims and the current actor identified by the outermost act claim. Prior actors in nested act claims are informational and must not factor into authorization decisions. This is a meaningful constraint — if your middleware is walking the entire act chain to make permit/deny decisions, it's non-compliant and likely producing incorrect security outcomes.


    4. The may_act claim — pre-authorisation baked into the subject token

    may_act solves a specific problem: how does the authorization server know that the party presenting an actor_token is actually allowed to act on behalf of the subject? One mechanism is out-of-band policy at the STS. Another is encoding the permission directly into the subject token.

    A subject token carrying may_act is saying: "the party identified below is pre-authorised to become the actor in a delegation exchange."

    {
      "sub": "user@example.net",
      "iss": "https://original-issuer.example.net",
      "exp": 1441910060,
      "scope": "status feed",
      "may_act": {
        "sub": "admin@example.net"
      }
    }
    

    When the authorization server receives a delegation request where the actor_token subject matches the may_act claim in the subject_token, it has a credential-based assertion that the delegation is legitimate — not just a policy rule configured separately. The same constraints apply as with act: no exp, nbf, or aud inside may_act, since these fields are identity claims, not validity claims.

    The relationship between may_act and act is sometimes confused. may_act lives in the input token (the subject_token) and expresses pre-authorised delegation. act lives in the output token (the issued token) and records who the current actor is. They work together but serve opposite directions: one is a permission grant going in, the other is an attestation of identity going out.


    5. What requested_token_type actually controls

    This is frequently misread. requested_token_type is a hint to the authorization server about what format you want the issued token to take. It does not guarantee you'll get that format. The server may issue something different, and issued_token_type in the response tells you what you actually got.

    The defined token type URIs matter here:

    urn:ietf:params:oauth:token-type:access_token   — opaque OAuth access token
    urn:ietf:params:oauth:token-type:refresh_token  — OAuth refresh token
    urn:ietf:params:oauth:token-type:id_token       — OIDC ID token
    urn:ietf:params:oauth:token-type:saml1          — base64url-encoded SAML 1.1
    urn:ietf:params:oauth:token-type:saml2          — base64url-encoded SAML 2.0
    urn:ietf:params:oauth:token-type:jwt            — explicitly a JWT
    

    The distinction between access_token and jwt is subtle and commonly conflated. access_token means: a normal OAuth access token issued by this authorization server, opaque to the client, usable the same way as any other access token from that server. It may well be a JWT internally, but the client doesn't need to know or care. jwt means specifically: a JWT — possibly for use as an authorization grant to a different authorization server, or in cross-domain scenarios where the structure must be transparent to the consumer.

    The id_token type is specific to OpenID Connect flows and is rarely the right choice for service-to-service token exchange.

    If you're exchanging a token within the same authorization domain for downstream service calls, access_token is almost always the right value to request. Using jwt signals that you need to inspect or relay the token's claims, which is usually a sign you're building something that's either a federation scenario or leaking too much into the client layer.


    6. The audience parameter and why it matters for microservices

    The audience parameter is OPTIONAL, but in a microservice architecture it's effectively necessary for secure operation. It's the mechanism by which you bind the issued token to a specific downstream service.

    There's a distinction between audience and resource. resource takes an absolute URI — the actual endpoint where you'll use the token. audience takes a logical identifier — an OAuth client ID, a SAML entity ID, an OIDC issuer identifier, whatever shared identifier both the client and the authorization server understand for that target service. They can be used together, and multiple values of each are permitted to express tokens intended for multiple targets.

    The security point is this: without specifying an audience (or resource), the issued token might be valid at any service in the same authorization domain. A token intended for the notifications service, if stolen, could be replayed against the payments service. Audience binding limits the blast radius of a compromised token to the specific service it was issued for.

    // Exchange the incoming user token for a backend-scoped token
    func exchangeForBackend(ctx context.Context, incomingToken, targetAudience string) (string, error) {
        form := url.Values{
            "grant_type":          {"urn:ietf:params:oauth:grant-type:token-exchange"},
            "subject_token":       {incomingToken},
            "subject_token_type":  {"urn:ietf:params:oauth:token-type:access_token"},
            "audience":            {targetAudience},
            "scope":               {"read:items"},
            "requested_token_type": {"urn:ietf:params:oauth:token-type:access_token"},
        }
        resp, err := httpClient.PostForm(tokenEndpoint, form)
        // ...
        return parseAccessToken(resp)
    }
    

    The RFC also defines invalid_target as the error code the server SHOULD use when it's unwilling or unable to issue a token for the requested target. This is a specific, actionable error — your retry logic should treat it differently from a general invalid_request.

    Audience values are treated as opaque identifiers scoped to a given authorization server. The RFC explicitly states they must be unique within that server to ensure correct interpretation. If you're running multiple AS instances and sharing audience identifiers across them, that's outside what the spec covers, and you're in deployment-specific territory.


    7. Scope restriction — narrowing authority at each hop

    One of the cleaner properties of token exchange is that scope can only shrink, not expand. An exchange request can ask for a subset of the scopes in the subject_token. Whether the authorization server honours a scope that wasn't present in the original token is a policy decision, but well-implemented servers reject it.

    In practice, this enables a useful pattern: a gateway receives a token with broad scopes (read:orders write:orders read:profile), and before forwarding to a read-only data service, exchanges it for a narrowly scoped token (read:orders). The data service never sees the write scope and cannot be induced to use it.

    The scope response field is OPTIONAL if the issued scope matches what was requested, and REQUIRED if it doesn't. This asymmetry is easy to miss. If you request read:orders and the server issues a token scoped to read:orders exactly, the scope field may be absent from the response and that's valid. If the server issues something narrower — say, read:orders:shipped — it must include the actual scope in the response. Clients that don't check the response scope field and just assume they got what they asked for will miscalculate their permissions.

    type ExchangeResponse struct {
        AccessToken      string `json:"access_token"`
        IssuedTokenType  string `json:"issued_token_type"`
        TokenType        string `json:"token_type"`
        ExpiresIn        int    `json:"expires_in"`
        Scope            string `json:"scope,omitempty"` // may be absent if unchanged
        RefreshToken     string `json:"refresh_token,omitempty"`
    }
    
    func grantedScopes(req string, resp ExchangeResponse) []string {
        effective := resp.Scope
        if effective == "" {
            effective = req // server confirmed requested scope
        }
        return strings.Fields(effective)
    }
    

    8. Trust propagation and the security perimeter

    The security considerations section of the RFC is short but points at a genuinely hard problem. Token exchange doesn't just move tokens around — it propagates trust. Each exchange is an assertion that the subject token is valid, that the client performing the exchange is authorised to do so, and that the resulting token accurately represents the delegated or impersonated identity.

    Client authentication to the authorization server matters significantly here. Without it, anyone who obtains a valid subject token can exchange it for tokens at other services — the STS becomes a force multiplier for compromised tokens. The RFC puts it plainly: omitting client authentication allows a compromised token to be leveraged via the STS into other tokens by anyone possessing it. This isn't hypothetical; it's describing the exact attack path you create by exposing an unauthenticated exchange endpoint.

    The scope constraint is the primary mitigation the RFC offers for delegation abuse. Beyond that, the specification is deliberately silent on the trust model — it does not prescribe how the STS should determine whether a given client is allowed to impersonate a given subject, or who may receive delegated authority from whom. Those are policy decisions left to individual deployments. This is a reasonable design choice, but it means the security of a token exchange deployment is almost entirely in the policies you configure, not in the protocol itself. An STS that accepts any client presenting any subject token and issues broad-scope tokens is compliant with the RFC and completely indefensible. Reviewing authorization server policy configuration — who can exchange what, for which audience — is the kind of finding that comes up consistently in an authentication code review.

    The RFC is also explicit that the exchange is a one-time event. There's no tight coupling between input and output tokens. Revoking the subject token does not automatically revoke the issued token. If you need revocation to propagate — which you almost certainly do in any serious deployment — that's an implementation-specific concern that the spec intentionally leaves out of scope.


    Token exchange sits at the intersection of two architectural pressures that aren't going away: the decomposition of systems into services with narrow trust boundaries, and the expectation that user context flows consistently through all of them. Zero-trust architectures need exactly what RFC 8693 provides — a standardised mechanism for a service to say "I hold proof of who this user is, now give me a credential scoped to exactly what I need to call this specific downstream service on their behalf." The alternative — ambient credentials, shared secrets, or services accepting tokens not issued for them — represents the kind of lateral movement surface that zero-trust is meant to eliminate. Token exchange is not complicated. What's complicated is wiring it into an authorization server with the right policies, enforcing audience binding at every hop, and resisting the temptation to skip the actor_token because impersonation is simpler to implement. The specification gives you the mechanism. Whether you use it correctly is a deployment problem.


    At Reverse Polarity, we review OAuth 2.0 token exchange deployments — authorization server policies, audience binding, and scope restrictions — as part of our authentication code reviews.


    Sources

    More Articles