Sitemap
Suman Shekhar Jana

Suman Shekhar Jana

Lead developer at Adobe | Reach me @ https://www.linkedin.com/in/ssjana/ | A Techie for by profession, an explorer by passion, a human by nature.

AEM | Workfront | CSC | Notify Workfront Fusion from AEM | JJWT — Cloud Manager Issues.

8 min readDec 1, 2025
Press enter or click to view image in full size

In CSC (Content Supply Chain) implementations, AEM as a Cloud Service often serves as a consumer or a middleware for Workfront data. Workfront, however, has no native visibility into what happens inside AEM for longer processes, whether it’s workflow progress or downstream processes involving App Builder, Dynamic Media, Content Hub, and more. This creates a visibility gap.

To bridge that, a Fusion webhook is needed to push status updates or data changes back to Workfront for accurate tracking.

In this blog, we’ll look at common API and some Cloud Manager challenges you may encounter, and walk through a practical solution to implement this integration.

Let’s look at the approach:

We will use an OAuth 2.0 JWT Bearer Token Grant implementation, which is a specific OAuth2 flow that uses JWT as the assertion mechanism. It’s neither purely JWT nor purely OAuth2, but rather OAuth2 using JWT as the grant type — a common pattern for server-to-server authentication in enterprise applications.

The Steps Involved:

  1. Ready the data to be posted. I will be posting JSON data to the Workfront Webhook from an AEM sling job. For validation purposes, we would fetch an access token and pass it in the request while passing the JSON data.
  2. First, we need to generate a JWT token using the provided private key.
  3. We’ll create a POST request, adding “client_id”, “client_secret”, and the generated JWT token in the request body.
  4. We need to fetch the “access_token” from the response body.
  5. Create another POST request, add the bearer/access token in the header, and add the JSON payload.
  6. Validate the Fusion webhook with the data.

The Setup:

Main POM.xml

<properties>
<jjwt.version>0.11.2</jjwt.version>
</properties>

I have used the 0.11.2 versions across. You can try with later versions. I faced a major issue with the 0.13.0 API versions [around November 2025], which I’ll discuss later in this blog.

<!-- JWT jjwt-api provides the public API interfaces  -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
<scope>provided</scope>
</dependency>
<!-- JWT jjwt-impl contains the runtime implementations -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- JWT jjwt-jackson adds JSON serialization support via Jackson. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>

core/POM.xml

<!-- JJWT dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>

all/POM.xml

<!-- JJWT embed-->
<embedded>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<type>jar</type>
<target>/apps/<myproject>-packages/content/install</target>
</embedded>
<embedded>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<type>jar</type>
<target>/apps/<myproject>-packages/content/install</target>
</embedded>
<embedded>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<type>jar</type>
<target>/apps/<myproject>-packages/content/install</target>
</embedded>

<!-- JJWT dependency-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>provided</scope>
</dependency>

Let’s set up some configuration (all details to be provided by the Workfront developer):

{
"clientId":"$[env:WORKFRONT_WEBHOOK_CLIENT_ID]",
"privateKey":"$[secret:WORKFRONT_WEBHOOK_PRIVATE_KEY]",
"clientSecret":"$[secret:WORKFRONT_WEBHOOK_CLIENT_SECRET]",
"userId":"$[env:WORKFRONT_WEBHOOK_USER_ID]",
"tokenEndpoint":"$[env:WORKFRONT_WEBHOOK_TOKEN_ENDPOINT]",
"customerId":"$[env:WORKFRONT_WEBHOOK_CUSTOMER_ID]",
"workfrontWebhookUrl":"$[env:WORKFRONT_WEBHOOK_URL]"
}

Where do you find these? Please reach out to your workfront counterpart. I am adding some details here:

  1. Navigate to Workfront instance → Setup → System → Oauth2 applications
Press enter or click to view image in full size

2. Create one app integration, select “Machine to Machine application”.

Get Suman Shekhar Jana’s stories in your inbox

Join Medium for free to get updates from this writer.

3. Click Add a public/private key, download the config file, and find the private key there.

4. Get other details too and proceed ahead!

We’ll write an object class definition for this config:

package com.myproject.core.config;

import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;

@ObjectClassDefinition(
name = "Workfront Auth Service Configuration",
description = "Configuration for Workfront OAuth JWT Authentication"
)
public @interface WorkfrontAuthConfig {

@AttributeDefinition(name = "Client ID", description = "Workfront API Client ID")
String clientId();

@AttributeDefinition(name = "Client Secret", description = "Workfront API Client Secret")
String clientSecret();

@AttributeDefinition(name = "User ID", description = "Workfront User ID")
String userId();

@AttributeDefinition(name = "Customer ID", description = "Workfront Customer ID")
String customerId();

@AttributeDefinition(name = "Private Key",
description = "RSA Private Key in PEM format (including BEGIN/END markers)")
String privateKey();

@AttributeDefinition(name = "Token Endpoint",
description = "Workfront JWT Exchange Endpoint")
String tokenEndpoint();

@AttributeDefinition(name = "Workfront Webhook URL",
description = "The Workfront Fusion webhook URL")
String workfrontWebhookUrl();

}

Next, we need an interface for our service:

package com.myproject.core.services;

/**
* Service for handling Workfront OAuth JWT authentication flow and API access
*/
public interface WorkfrontAuthService {
/**
* Generates a JWT token signed with the private key
* @return The generated JWT token
*/
String generateJWT();

/**
* Exchanges a JWT token for an access token
*/
String getAccessToken();

/**
* Get the Workfront URL for Fusion webhook
* @return The Workfront Fusion webhook URL
*/
String getWorkfrontWebhookUrl();

/**
* post Status To Workfront
* @param webhookUrl webhookUrl
* @param accessToken accessToken
* @param jsonString jsonString
*/
void postStatusToWorkfront(final String webhookUrl, final String accessToken, final String jsonString);

}

and the implementation of it:

package com.myproject.core.services.impl;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.myproject.core.config.WorkfrontAuthConfig;
import com.myproject.core.services.WorkfrontAuthService;
import com.myproject.core.utils.Constants;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.apache.sling.servlets.post.JSONResponse;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.metatype.annotations.Designate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;

/**
* Implementation of the WorkfrontAuthService for generating access tokens
* using the OAuth JWT flow for Workfront API
*/
@Component(service = WorkfrontAuthService.class, immediate = true)
@Designate(ocd = WorkfrontAuthConfig.class)
public class WorkfrontAuthServiceImpl implements WorkfrontAuthService {

private static final Logger LOGGER = LoggerFactory.getLogger(WorkfrontAuthServiceImpl.class);

private String clientId;
private String clientSecret;
private String userId;
private String customerId;
private String privateKeyString;
private String tokenEndpoint;
private String workfrontWebhookUrl;

@Activate
@Modified
protected void activate(final WorkfrontAuthConfig config) {
this.clientId = config.clientId();
this.clientSecret = config.clientSecret();
this.userId = config.userId();
this.customerId = config.customerId();
this.privateKeyString = config.privateKey();
this.tokenEndpoint = config.tokenEndpoint();
this.workfrontWebhookUrl = config.workfrontWebhookUrl();
LOGGER.debug("Workfront Auth Service activated with client ID: {}", clientId);
}

/**
* get Workfront Webhook Url
*/
@Override
public String getWorkfrontWebhookUrl() {
return workfrontWebhookUrl;
}

/**
* generateJWT
*/
@Override
public String generateJWT() {
String jwt = StringUtils.EMPTY;
try {
String key = privateKeyString
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");

byte[] privateKeyBytes = Base64.getDecoder().decode(key);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec);

Instant now = Instant.now();
Instant expiration = now.plusSeconds(600);

jwt = Jwts.builder()
.setHeaderParam("typ", "JWT")
.claim("iss", customerId)
.claim("sub", userId)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiration))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
} catch (Exception e) {
LOGGER.error("Failed to generate JWT token, exception occurred:", e);
}
return jwt;
}



/**
* Exchange JWT for an access token
*
* @param jwt The JWT token to exchange
* @return The access token
* @throws IOException If there's an error during the HTTP request
*/
private String exchangeJwtForAccessToken(String jwt) throws IOException {
HttpPost httpPost = getHttpPost(jwt);
try (CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = httpClient.execute(httpPost)) {
String responseBody = EntityUtils.toString(response.getEntity());
JsonObject jsonResponse = JsonParser.parseString(responseBody).getAsJsonObject();
return jsonResponse.get("access_token").getAsString();
}
}


/**
* get created Http Post call.
* @param jwt String value
*/
private HttpPost getHttpPost(String jwt) throws UnsupportedEncodingException {
HttpPost httpPost = new HttpPost(tokenEndpoint);

JsonObject requestBody = new JsonObject();
requestBody.addProperty("client_id", clientId);
requestBody.addProperty("client_secret", clientSecret);
requestBody.addProperty("jwt_token", jwt);

StringEntity entity = new StringEntity(requestBody.toString());
httpPost.setEntity(entity);
httpPost.setHeader("Content-Type", JSONResponse.RESPONSE_CONTENT_TYPE);
return httpPost;
}


/**
* get Access Token
*/
@Override
public String getAccessToken() {
String accessToken = StringUtils.EMPTY;
try {
String jwt = generateJWT();
LOGGER.debug("JWT generated for access token request");
accessToken = exchangeJwtForAccessToken(jwt);
} catch (IOException e) {
LOGGER.error("Failed to obtain access token: ", e);
}
return accessToken;
}


/**
* post Status To Workfront
* @param jsonString jsonString
*/
@Override
public void postStatusToWorkfront(final String webhookUrl, final String accessToken, final String jsonString) {
LOGGER.info("preparing to post status to Workfront");
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {

if (StringUtils.isNotBlank(webhookUrl) && StringUtils.isNotBlank(accessToken)) {
HttpPost httpPost = createHttpRequest(webhookUrl, accessToken, jsonString);
executeHttpRequest(httpPost, httpClient);
} else {
LOGGER.error("webhookUrl:: {}, or accessToken:: {} or both are unavailable", webhookUrl, accessToken);
}

} catch (IOException e) {
LOGGER.error("IO Exception in sending status to Workfront Webhook", e);
}
}


/**
* createHttpRequest
* @param webhookUrl webhookUrl
* @param accessToken accessToken
* @param jsonPayload jsonPayload
*/
private HttpPost createHttpRequest(String webhookUrl, String accessToken, String jsonPayload) throws IOException {
HttpPost httpPost = new HttpPost(webhookUrl);
httpPost.setHeader(Constants.HEADER_CONTENT_TYPE, JSONResponse.RESPONSE_CONTENT_TYPE);
httpPost.setHeader(Constants.HEADER_AUTHORIZATION, Constants.BEARER_PREFIX + accessToken);
StringEntity entity = new StringEntity(jsonPayload);
httpPost.setEntity(entity);
LOGGER.info("HTTP POST request created for Workfront");
return httpPost;
}


/**
* execute POST Workfront HttpRequest
* @param httpPost httpPost
* @param httpClient httpClient
*/
private void executeHttpRequest(HttpPost httpPost, CloseableHttpClient httpClient) {
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
int statusCode = response.getStatusLine().getStatusCode();
LOGGER.info("Workfront API response status code: {}", statusCode);
if (LOGGER.isDebugEnabled() && response.getEntity() != null) {
String responseBody = EntityUtils.toString(response.getEntity());
}
} catch (IOException e) {
LOGGER.error("Error executing HTTP request to {}: {}", httpPost.getURI(), e.getMessage(), e);
}
}

}

Finally, a sling job for all the work (or a servlet as needed):

package com.myproject.core.jobs;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.myproject.core.services.WorkfrontAuthService;
import com.myproject.core.utils.Constants;
import org.apache.jackrabbit.spi.commons.query.QueryConstants;
import org.apache.sling.event.jobs.Job;
import org.apache.sling.event.jobs.consumer.JobConsumer;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Component(
service = JobConsumer.class,
immediate = true,
property = {JobConsumer.PROPERTY_TOPICS + QueryConstants.OP_NAME_EQ_GENERAL + Constants.SOME_JOB}
)
public class TestJobConsumer implements JobConsumer {

private final Logger log = LoggerFactory.getLogger(TestJobConsumer.class);

@Reference
private WorkfrontAuthService workfrontAuthService;



@Override
public JobResult process(Job job) {
try {
String jsonString = getJsonString();
String webhookUrl = workfrontAuthService.getWorkfrontWebhookUrl();
String accessToken = workfrontAuthService.getAccessToken();
workfrontAuthService.postStatusToWorkfront(webhookUrl, accessToken, jsonString);
return JobResult.OK;
} catch (JsonProcessingException e) {
log.error("JsonProcessingException", e);
return JobResult.FAILED;
}
}

private String getJsonString() throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode resultJson =objectMapper.createObjectNode();

//update logic to fetch the following hard-coded data.

resultJson.put("deliverableFolder", "/content/dam/myproject/somepath");
resultJson.put("wfTaskID", "someWorkfrontTaskId");
resultJson.put("status", "Success/PartialSuccess/Failure");
resultJson.put("totalAssetsProcessed", 100);
resultJson.put("success", 50);
resultJson.put("failure", 40);

String jsonString = objectMapper.writeValueAsString(resultJson);
log.info("json: {}", jsonString);
return jsonString;
}

}

The issues discussion:

The above code (with jjwt APIs using 0.11.2 versions) works perfectly fine. However, you would have an urge to use the latest versions (say 0.13.0).

<properties>
<jjwt.version>0.13.0</jjwt.version>
</properties>

The method to fetch a JWT token would also need an update with Java-21:

public String generateJWT() {
String jwt = StringUtils.EMPTY;
try {
String key = privateKeyString
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
byte[] privateKeyBytes = Base64.getDecoder().decode(key);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec);
Instant now = Instant.now();
Instant expiration = now.plusSeconds(600);

/* Code for jjwt api version 0.13.0 */

jwt = Jwts.builder()
.header().type("JWT").and()
.claim("iss", customerId)
.claim("sub", userId)
.issuedAt(Date.from(now))
.expiration(Date.from(expiration))
.signWith(privateKey, Jwts.SIG.RS256)
.compact();

/* Code for jjwt api version 0.11.2 */

/*jwt = Jwts.builder()
.setHeaderParam("typ", "JWT")
.claim("iss", customerId)
.claim("sub", userId)
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiration))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();*/
} catch (Exception e) {
LOGGER.error("Failed to generate JWT token, exception occurred:", e);
}
return jwt;
}
  1. This would work great in the local SDK. I verified.
  2. But (as of November 2025) it would fail in the cloud manager build at “Deploy to Dev” step — “update services and install mutable content sub-steps”.
  3. The deployment would run for 5–6 hours before failing. And, you cannot kill it as a cloud manager user.
  4. The cloud manager log would not reveal any details.
  5. You would find some indicative details in the error log of the environment pods:
*ERROR* [sling-default-5-Registered Service.9293] org.apache.sling.commons.scheduler.impl.QuartzScheduler Exception during job execution of job 'com.adobe.granite.toggle.impl.ToggleRouterImpl@746c868b' with name 'Registered Service.9293' : 'io.jsonwebtoken.JwtBuilder io.jsonwebtoken.JwtBuilder.setIssuer(java.lang.String)'
java.lang.NoSuchMethodError: 'io.jsonwebtoken.JwtBuilder io.jsonwebtoken.JwtBuilder.setIssuer(java.lang.String)'
at com.adobe.granite.toggle.sdk.services.TokenService.generateJwt(TokenService.java:86) [com.adobe.granite.toggle.impl.remote:1.1.8]
at com.adobe.granite.toggle.sdk.services.ImsService.retrieveAuthenticationHeaders(ImsService.java:74) [com.adobe.granite.toggle.impl.remote:1.1.8]
at com.adobe.granite.toggle.sdk.services.ToggleServiceImpl.retrieveToggles(ToggleServiceImpl.java:73) [com.adobe.granite.toggle.impl.remote:1.1.8]
at com.adobe.granite.toggle.sdk.services.ToggleServiceImpl.getEnabledFlags(ToggleServiceImpl.java:62) [com.adobe.granite.toggle.impl.remote:1.1.8]
at com.adobe.granite.toggle.impl.ToggleRouterImpl.getUpdatedToggles(ToggleRouterImpl.java:229) [com.adobe.granite.toggle.impl.remote:1.1.8]
at com.adobe.granite.toggle.impl.ToggleRouterImpl.calculateToggleState(ToggleRouterImpl.java:330) [com.adobe.granite.toggle.impl.remote:1.1.8]
at com.adobe.granite.toggle.impl.ToggleRouterImpl.update(ToggleRouterImpl.java:348) [com.adobe.granite.toggle.impl.remote:1.1.8]
at com.adobe.granite.toggle.impl.ToggleRouterImpl.run(ToggleRouterImpl.java:220) [com.adobe.granite.toggle.impl.remote:1.1.8]
at org.apache.sling.commons.scheduler.impl.QuartzJobExecutor.execute(QuartzJobExecutor.java:349) [org.apache.sling.commons.scheduler:2.7.14]
at org.quartz.core.JobRunShell.run(JobRunShell.java:202) [org.apache.sling.commons.scheduler:2.7.14]
at org.apache.sling.commons.scheduler.impl.QuartzThreadPool.lambda$runInThread$0(QuartzThreadPool.java:83) [org.apache.sling.commons.scheduler:2.7.14]
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Press enter or click to view image in full size

But I hope this was helpful for your use case. Please leave a clap if you like it, and as always, I’ll look forward to your comments and emails with suggestions for improvement. Thanks for your time!

--

--

Suman Shekhar Jana
Suman Shekhar Jana

Written by Suman Shekhar Jana

Lead developer at Adobe | Reach me @ https://www.linkedin.com/in/ssjana/ | A Techie for by profession, an explorer by passion, a human by nature.