On this page
Intro
The Zero Trust API Events architecture article explains how to use token exchange to resume asynchronous flows while maintaining sensitive values in a digitally verifiable manner. This tutorial provides an example implementation that you can run end-to-end on a development computer. It uses the popular Apache Kafka event streaming platform as the messaging middleware.
Components
The following diagram shows the components in the flow. The user facing client runs a code flow to get a user level access token. The client then uses the access token to create an order in the Orders API. The Orders API then raises an OrderCreated
event, which a separate Invoices API consumes asynchronously. The flow enables the Orders API to communicate sensitive values to the Invoices API using a JWT access token, which has a long lifetime, but very limited permissions:
In some scenarios the asynchronous processing might continue after a delay. Examples include end of day batch jobs or workflow processes that require approval from an administrator. You can use the same design patterns for any other system that uses event messages for asynchronous flows.
Deploy the System
First clone the GitHub repository at the top of this page, which contains a number of resources including the orders-api
and invoices-api
. These APIs implement zero trust using simple Node.js code. The deployment includes the Kafka system, an instance of the Curity Identity Server, and an API gateway that uses the Phantom Token Pattern to swap opaque access tokens from the client for JWT access tokens that the API receives.
Ensure that you have Docker and Node.js installed. Then copy a license file for the Curity Identity Server into the idsvr
folder of the project. If you do not have a license file, you can get a free community edition license by signing into the Curity Developer Portal with your Github account. After copying the license, execute the following scripts to build and deploy all backend components to a Docker Compose network. Wait a few minutes for some large Docker resources to build and download.
./build.sh./deploy.sh
The APIs run on the local computer by default, to make their log output visible. If required, adapt the code in the orders-api
and invoices-api
folders, then restart the APIs by running npm start
.
Run the Event Flow
Execute the following commands to begin the flow and trigger a user login in the system browser. The console client requests a scope of openid profile orders
and does not have direct permissions to call the Invoices API. The client listens for the login response on a loopback HTTP address at http://127.0.0.1:3003
. You can read more about how to implement OAuth for that kind of client in RFC8252 - OAuth for Native Apps.
cd console-clientnpm installnpm start
When prompted, sign in with the credentials demouser / Password1
so that the console client receives a user level access token. The client then sends the access token to the Orders API along with some example order details. The console client then simply exits.

The initial access token that the Orders API receives has a payload of the following form, with multiple scopes and a short lifetime of 15 minutes.
{"jti": "3ed02b8d-503d-46f1-bffa-e38d30a4c9d3","delegationId": "9dd15191-9441-4056-9e56-52b7ad116b18","exp": 1736154372,"nbf": 1736154072,"scope": "openid profile orders","iss": "http://localhost:8443/oauth/v2/oauth-anonymous","sub": "demouser","aud": "api.example.com","iat": 1736154072,"purpose": "access_token"}
The Orders API processes the HTTP request and creates a transaction record:
{"eventID": "5a704593-6f1f-45e4-886a-e37fe5848dc7","utcTime": "2025-01-06T09:01:12.405Z","items": [{"itemID": 1,"quantity": 1,"price": 100},{"itemID": 3,"quantity": 3,"price": 100}]}
The Orders API then uses token exchange to get a new access token to send to the Invoices API. The Curity Identity Server issues the userID
and transactionID
fields to the access token. The Kafka message header contains the access token. The payload of the message has the following form.
export interface OrderCreatedEvent {eventID: string;utcTime: number;items: OrderItem[];}
Shortly after, the Invoices API receives the event message. It first validates the JWT access token and retrieves sensitive values from it. Next it processes the event data and creates an invoice, which a real API might store in its database.
{"invoiceID": "43f99a41-f50b-443d-813f-3f5eee21851b","transactionID": "0d2e8437-d4e3-40e1-8d6e-a6e17d2c04c7","userID": "demouser","utcTime": "2025-01-06T09:01:13.686Z","amount": 400}
The userID
and transactionID
represent sensitive values that the source API communicates to the target API in a JWT access token rather than the event payload. This keeps the values verifiable and auditable. Architects can choose which values to protect in this manner and might include money values in some use cases.
Token Exchange
To get the access token for a new event message, the Orders API sends a token exchange request to the Curity Identity Server to upscope the original access token. This mechanism enables the original client to only use the orders
scope so that it does not need to know about the internal scopes in upstream processing. You must authorize the upscoping in the Curity Identity Server by configuring the trigger_invoicing
scope against the Orders API's OAuth client. This scope would only grant limited invoice privileges.
POST /oauth/v2/oauth-token HTTP/1.1Host: localhost:8443Content-Type: application/x-www-form-urlencodedgrant_type=urn:ietf:params:oauth:grant-type:token-exchange&client_id=orders-api-client&client_secret=Password1&subject_token=eyJraWQiOiItMTcyNTQxNzE2NyIsIng...&subject_token_type=urn:ietf:params:oauth:token-type:access_token&scope=trigger_invoicing&audience=jobs.example.com&transaction_id=6b69df74-339f-416b-84bb-f1f4f32d8f1a&event_id=e80be47d-7282-4f3c-898a-709ca5393aa5
In the Curity Identity Server a JavaScript token procedure then executes to apply custom logic during token exchange. You can read more about the token exchange mechanics in the Implementing Token Exchange tutorial.
function result(context) {var tokenData = context.getPresentedSubjectToken();var presentedDelegation = context.getPresentedSubjectTokenDelegation();if (tokenData === null) {throw exceptionFactory.badRequestException('invalid_request', 'Invalid subject_token');}var requestedScope = context.request.getFormParameter('scope');if (!requestedScope) {throw exceptionFactory.badRequestException('invalid_request', 'Missing scope in a token exchange request');}var requestedAudience = context.request.getFormParameter('audience');if (!requestedAudience) {throw exceptionFactory.badRequestException('invalid_request', 'Missing audience in a token exchange request');}var transactionId = context.request.getFormParameter('transaction_id');if (!transactionId) {throw exceptionFactory.badRequestException('invalid_request', 'Missing transaction_id in a token exchange request');}var eventId = context.request.getFormParameter('event_id');if (!eventId) {throw exceptionFactory.badRequestException('invalid_request', 'Missing event_id in a token exchange request');}var now = new Date();var future = new Date();future.setDate(now.getDate() + 7);var expirySeconds = Math.round(future.getTime() / 1000);var audiences = [requestedAudience];var scopes = requestedScope.split(' ');tokenData.aud = requestedAudience;tokenData.scope = requestedScope;tokenData.exp = expirySeconds;tokenData.transaction_id = transactionId;tokenData.event_id = eventId;var fullContext = context.getInitializedContext(context.subjectAttributes(),context.contextAttributes(),audiences,scopes);var issuedAccessToken = fullContext.getDefaultAccessTokenJwtIssuer().issue(tokenData, presentedDelegation);return {scope: tokenData.scope,claims: tokenData.claims,access_token: issuedAccessToken,token_type: 'bearer',expires_in: secondsUntil(tokenData.exp),issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',};}
The new access token has the requested scope and a new audience, which only messaging endpoints accept as part of processing asynchronous jobs. The token is bound to a single event message and transaction ID so it has restricted privileges. The access token has a long lifetime and expires 1 week in the future.
{"jti": "14c4ac0c-7806-401b-b6db-94ae1b6f8d4a","delegationId": "9dd15191-9441-4056-9e56-52b7ad116b18","exp": 1736758872,"nbf": 1736154072,"scope": "trigger_invoicing","iss": "http://localhost:8443/oauth/v2/oauth-anonymous","sub": "demouser","aud": "jobs.example.com","iat": 1736154072,"purpose": "access_token","transaction_id": "0d2e8437-d4e3-40e1-8d6e-a6e17d2c04c7","event_id": "5a704593-6f1f-45e4-886a-e37fe5848dc7"}
Event Publishing and Consuming
The Orders API uses straightforward code to implement token exchange that binds the precise event ID and transaction ID to the new access token. The Orders API then publishes the OrderCreated
event:
export async function publishOrderCreated(orderTransaction: OrderTransaction, accessToken: string, producer: Kafka.Producer) {const eventID = randomUUID();const longLivedReducedScopeAccessToken = await tokenExchange(accessToken, eventID, orderTransaction.transactionID);const orderCreatedEvent = {eventID,utcTime: orderTransaction.utcTime,items: orderTransaction.items,} as OrderCreatedEvent;const partition = null;const message = Buffer.from(JSON.stringify(orderCreatedEvent));const key = null;const timestamp = null;const opaque = null;const headers: Kafka.MessageHeader[] = [{'Authorization': `Bearer ${longLivedReducedScopeAccessToken}`,}];producer.produce('OrderCreated', partition, message, key, timestamp, opaque, headers);}
To consume the event, the Invoices API implements a message handler which first retrieves the JWT access token from the message header and validates it before running business logic to create an invoice.
.on('data', async (message: Kafka.Message) => {try {const orderEvent = JSON.parse(message.value.toString());const key = 'Authorization';const authorizationHeader = message.headers?.find((h) => h[key])?.[key]?.toString() || '';const claims = await validateAsyncAccessToken(authorizationHeader, orderEvent.eventId);createInvoice(orderEvent, claims);} catch (e: any) {const error = e as InvoiceServiceError;logError(error);}}
The Invoices API implements some additional checks when it performs access token validation for messaging endpoints. These extra checks ensure that long lived access tokens are rejected at HTTP endpoints, which can help prevent mistakes and detect token misuse.
- The API only accepts access tokens with an audience that matches
jobs.example.com
. - The API only accepts access tokens with a scope of
trigger_invoicing
. - The API only accepts access tokens with an
event_id
claim that matches the ID of the Kafka message.
export async function validateAsyncAccessToken(authorizationHeader: string, eventID: string): Promise<ClaimsPrincipal> {const accessToken = readAccessToken(authorizationHeader);const result = await validateAccessToken(accessToken, oauthJobsConfiguration);const scope = (result.payload.scope as string).split(' ');if (scope.indexOf(oauthJobsConfiguration.scope) === -1) {throw new InvoiceServiceError(403, 'insufficient_scope', 'The access token has insufficient scope');}if (!result.payload.event_id || !result.payload.transaction_id) {throw new InvoiceServiceError(403, 'insufficient_scope', 'The access token does not have the required claims');}if (result.payload.event_id !== eventID) {throw new InvoiceServiceError(403, 'invalid_message', 'The event message does not match the event ID in the access token');}return {userID: result.payload.sub as string,scope,transactionID: result.payload.transaction_id as string,};}
Conclusion
This tutorial explains how to use JWT access tokens to secure event messages, to use strong identity guarantees when resuming asynchronous workflows. OAuth secured APIs already verify JWTs issued by the Identity Server when receiving HTTP requests. Therefore, zero trust API events do not require any additional key management infrastructure. The code impact should also be minor if the API already processes JWTs at its HTTP endpoints.
Join our Newsletter
Get the latest on identity management, API Security and authentication straight to your inbox.
Start Free Trial
Try the Curity Identity Server for Free. Get up and running in 10 minutes.
Start Free Trial