BetterIAM

Token exchange

Let one of your APIs trade a caller's access token for a narrower token to another API, keeping the person as subject and recording the calling API as actor (RFC 8693).

Token exchange (RFC 8693) lets an API that received a person's access token get a new token for a different API, for the same person. The invoices API, handling Ada's request, needs Ada's reports. Forwarding Ada's token does not work: it was issued for the invoices API only, and the reports API refuses it. Calling with the invoices API's own service identity loses track of who the request is for. Token exchange gives the invoices API a token for the reports API that still names Ada as the subject (sub) and names the invoices API as the actor (act).

Use it for service-to-service calls that act for a person, when each API accepts only tokens issued for it (resource indicators). Token exchange is part of the OAuth/OIDC provider and always available; a client may use it only when it is registered for the urn:ietf:params:oauth:grant-type:token-exchange grant type.

Not the same as delegation tokens

This page covers OAuth token exchange at the provider's token endpoint, between your APIs. An AI agent acting for a person outside Better IAM uses a delegation token from iam.api.delegations.issueToken instead, a separate feature described in Call services outside Better IAM as the person.

How it works

Setup

Declare both APIs as resource servers

Both the API that receives the person's token and the API it calls are resource servers of the provider:

oauth.ts
import { createOAuthProvider } from 'better-iam/oauth';
import { iam } from './iam';

const keys = JSON.parse(process.env.OIDC_KEYS!); // { jwks, cookieKeys, encryptionKey }

export const issuer = createOAuthProvider({
  ...iam.protocolHost,
  issuer: 'https://identity.example/oidc',
  jwks: keys.jwks,
  cookieKeys: keys.cookieKeys,
  encryptionKey: keys.encryptionKey,
  trustedOrigins: ['https://identity.example'],
  interactionUrl: (uid) => `https://identity.example/interactions/${uid}`,
  renderDevicePage: ({ form }) => `<main>${form}</main>`,
  renderLogoutPage: ({ form }) => `<main>${form}</main>`,
  resourceServers: {
    'https://invoices.example': { scopes: ['invoices:read'] },
    'https://reports.example': { scopes: ['reports:read', 'reports:admin'] },
  },
  // Optional: your own rule, checked after the built-in ones.
  authorizeTokenExchange: ({ clientId, subjectClientId, scopes }) =>
    clientId === 'invoices-api' && subjectClientId === 'invoices-web' && !scopes.includes('reports:admin'),
});
iam.useProtocol(issuer);

Register the calling API as a client

The API that exchanges tokens is a confidential client registered for the token exchange grant, with the downstream API in resources and the scopes it may use there in scopes:

scripts/register-invoices-api.ts
import { issuer } from '../oauth';

const { clientSecret } = await issuer.registerClient(adminCredential, {
  tenantId: 'acme',
  clientId: 'invoices-api',
  name: 'Invoices API',
  redirectUris: [],
  grantTypes: ['urn:ietf:params:oauth:grant-type:token-exchange'],
  scopes: ['reports:read'],
  resources: ['https://reports.example'],
});
// Store clientSecret in the invoices API's secret store. It is shown once.

adminCredential is an administrator's IAM credential with iam:oauth:clients:create. Public clients cannot be registered for token exchange (INVALID_INPUT), and the client must not have requireDpop: token exchange issues bearer tokens only.

Verify exchanged tokens in the downstream API

The reports API verifies the exchanged token like any other, and reads the acting API from actor:

reports/auth.ts
import { createAccessTokenVerifier } from 'better-iam/oauth';

export const verifier = createAccessTokenVerifier({
  issuer: 'https://identity.example/oidc',
  audience: 'https://reports.example',
});

Usage

Exchange a token

The calling API posts the person's access token to the token endpoint, authenticated as itself:

invoices/reports-client.ts
const tokenEndpoint = 'https://identity.example/oidc/token';
const basic = Buffer.from(`invoices-api:${process.env.INVOICES_API_SECRET}`).toString('base64');

export async function exchangeForReports(subjectToken: string) {
  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: {
      authorization: `Basic ${basic}`,
      'content-type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: subjectToken,
      subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
      resource: 'https://reports.example',
      scope: 'reports:read',
    }),
  });
  const body = (await response.json()) as Record<string, string | number>;
  if (!response.ok) throw new Error(`Token exchange failed: ${body.error}`);
  return body as {
    access_token: string;
    issued_token_type: 'urn:ietf:params:oauth:token-type:access_token';
    token_type: 'Bearer';
    expires_in: number;
    scope: string;
  };
}

Endpoint: POST {issuer}/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange

ParameterRequiredValue
subject_tokenyesThe access token the calling API received.
subject_token_typeyesAlways urn:ietf:params:oauth:token-type:access_token.
resource or audienceyesOne resource indicator from the client's registered resources.
scopenoSpace-separated scopes. Omit to receive every scope the client and the target both allow.
requested_token_typenoOnly urn:ietf:params:oauth:token-type:access_token is accepted.
actor_tokenrefusedThe authenticated client is always the actor.

Each parameter may appear once. The client authenticates the way it was registered: client_secret_basic (above), client_secret_post, or private_key_jwt.

Call the downstream API

invoices/routes/summary.ts
import { exchangeForReports } from '../reports-client';

export async function GET(request: Request) {
  const subjectToken = request.headers.get('authorization')!.replace(/^Bearer /, '');
  const { access_token } = await exchangeForReports(subjectToken);
  const reports = await fetch('https://reports.example/reports', {
    headers: { authorization: `Bearer ${access_token}` },
  });
  return new Response(reports.body, { status: reports.status });
}

Verify the incoming token with the invoices API's own verifier before exchanging it. The exchange checks it again, but your API should not act on a request it has not authenticated.

Read the actor in the downstream API

reports/routes/list.ts
import { IamError } from 'better-iam';
import { verifier } from '../auth';

export async function GET(request: Request) {
  try {
    const token = await verifier.verifyRequest(
      {
        authorization: request.headers.get('authorization'),
        dpop: request.headers.get('dpop'),
        method: request.method,
        url: request.url,
      },
      { scopes: ['reports:read'] },
    );
    // token.subject: the person. token.clientId and token.actor.sub: 'invoices-api'.
    return Response.json({ person: token.subject, via: token.actor?.sub, tenant: token.tenantId });
  } catch (error) {
    if (!(error instanceof IamError)) throw error;
    return new Response(null, { status: error.status, headers: { 'www-authenticate': verifier.challenge(error) } });
  }
}

What the issued token contains

ClaimValue
subThe account of the subject token: the person, or for a client-credentials subject token, its service account.
client_idThe exchanging client (invoices-api).
act{ sub: <exchanging client> }. When the subject token was itself exchanged, its act is nested inside: { sub: 'billing-api', act: { sub: 'invoices-api' } }.
audThe target resource server's audience, or its resource indicator.
scopeThe granted scopes.
tenant_idThe exchanging client's tenant.
expThe earliest of: the target's accessTokenTtl (default 900 seconds), the client's accessTokenTtl, and the subject token's own expiry.

The format is the target resource server's accessTokenFormat (JWT by default). Each exchange is appended to the tenant's audit chain as iam:oauth:TokenExchange, with the subject account as actor and the exchanging client as resource.

Rules the provider enforces

  • The subject token is a JWT access token signed by this issuer (typ: at+jwt) or an opaque access token the provider stores. ID tokens and refresh tokens are refused.
  • It must not be DPoP-bound: the exchanging client cannot prove the original client's key.
  • It must have been issued to a client that is not revoked and is in the exchanging client's tenant. For a person's JWT token, the person's consent to that client must still exist, so disconnecting the app stops its tokens from being exchanged.
  • The account it names must be active, in the same tenant, and the tenant must be active.
  • The target must be both a declared resource server and one of the exchanging client's resources.
  • Granted scopes are the requested ones, each allowed by the client's scopes and the target's scopes. One refused scope fails the whole request.
  • authorizeTokenExchange, when set, runs last and can refuse with access_denied.

Add your own policy

Without authorizeTokenExchange, any client registered for the grant may exchange for its own resources and scopes. Add it to restrict which apps' tokens an API may exchange, or which scopes it may delegate. It receives a TokenExchangeRequest and returns true to allow:

FieldTypeMeaning
tenantIdstringThe tenant of the client and the account.
clientIdstringThe exchanging client, which becomes the actor.
identityIdstringThe account the subject token represents.
subjectClientIdstringThe client the subject token was issued to.
subjectScopesstring[]The subject token's scopes.
resourcestringThe target resource indicator.
scopesstring[]The scopes about to be granted.

Endpoints

EndpointPath (relative to the issuer)Discovery field
TokenPOST /tokentoken_endpoint
Public keys, for verifying JWT tokensGET /jwksjwks_uri
Introspection, for opaque tokensPOST /token/introspectionintrospection_endpoint

The token exchange grant type is listed in the discovery document's grant_types_supported.

Options

OptionTypeDefaultDescription
authorizeTokenExchange (provider)(request: TokenExchangeRequest) => boolean | Promise<boolean>noneYour rule after the built-in checks. Return false to answer access_denied.
resourceServers (provider)Record<string, OAuthResourceServer>{}The APIs tokens can be exchanged for, with their scopes, audience, lifetime, and format.
grantTypes (client)OAuthGrantType[]authorization_code, refresh_tokenMust include urn:ietf:params:oauth:grant-type:token-exchange. Confidential clients only.
resources (client)string[]noneThe targets this client may exchange for.
scopes (client)string[]openid email profile offline_accessThe most this client may receive at any target.
accessTokenTtl (client)numberresource server lifetimeShortens exchanged tokens further, 60 to 86,400 seconds.
tokenEndpointAuthMethod (client)'client_secret_basic' | 'client_secret_post' | 'private_key_jwt''client_secret_basic'How the exchanging client authenticates.

Errors

The token endpoint answers with standard OAuth error codes in a JSON body:

errorWhen
invalid_requestThe client is not registered for the grant, subject_token is missing, subject_token_type or requested_token_type is not an access token, actor_token was sent, a parameter is repeated, or the client requires DPoP.
invalid_clientThe client failed authentication or is revoked.
invalid_grantThe subject token is invalid, expired, DPoP-bound, from a revoked or foreign client, its consent was revoked, or its account or tenant is inactive.
invalid_targetNo resource or audience, or one the client may not call.
invalid_scopeA requested scope is not allowed for the client at that target, or nothing is left to grant.
access_deniedauthorizeTokenExchange returned false.

Registering the client can fail with INVALID_INPUT (a public client, an unknown resource indicator, or an unsupported scope) or CONFLICT (the client ID exists). In the downstream API, the verifier fails with INVALID_TOKEN (401) or INSUFFICIENT_SCOPE (403).

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page