/images/resources/tutorials/writing-apis/api-using-zero-trust-events.png

Securing API Events using JWTs

Advanced Security
QualityAvailability
Download on GitHub
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:

End to End Flow

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.

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

bash
123
cd console-client
npm install
npm 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.

System Browser Login

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.

json
123456789101112
{
"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:

text
12345678910111213141516
{
"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.

typescript
12345
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.

text
1234567
{
"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.

http
12345678910111213
POST /oauth/v2/oauth-token HTTP/1.1
Host: localhost:8443
Content-Type: application/x-www-form-urlencoded
 
grant_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.

javascript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
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.

json
1234567891011121314
{
"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:

typescript
123456789101112131415161718192021222324
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.

typescript
123456789101112131415161718
.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.
typescript
123456789101112131415161718192021222324
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