1. The Problem
Imagine a team building a headless API on top of AEM Cloud Service. They expose page
metadata — title, tags, creation date, and creator — through a Sling Model backed by
a JSON exporter. On the Author preview everything looks correct: jcr:createdBy
shows [email protected].
Then they test the same endpoint on Publish. The creator field reflects a system user — the distribution service — instead of the real author. Support tickets open. The team suspects a bug in their code. Debugging begins.
There is no bug. This is how AEM Cloud Service works — by design.
The following side-by-side illustrates the discrepancy:
Notice that cq:lastReplicatedBy is intact — it correctly records who
triggered the publish action. Only the JCR-managed properties (jcr:createdBy
and jcr:lastModifiedBy) differ. Understanding why requires a quick look at
the AEM Cloud publishing pipeline.
2. How Publishing Works in AEM Cloud
Legacy AEM 6.5 / AMS — Classic Replication
In AEM 6.5 and Managed Services, content is moved from Author to Publish through
replication agents. An agent opens an HTTP connection directly to a
specific Publish instance, serialises the content into a package, and POSTs it. The
Publish instance unpacks the content using a configured replication-receiver service user.
The same root cause applies here — that service user becomes jcr:createdBy
on Publish — but the behaviour is less visible because teams rarely inspect CRXDE on Publish.
AEM as a Cloud Service — Sling Content Distribution
AEMaaCS replaces replication agents entirely with Sling Content Distribution (SCD). The architecture is fundamentally different:
sling-distribution-importer service session — not the original author's session.
Key architectural properties of SCD that matter here:
- Stateless pipeline: The distribution pipeline is managed entirely by Adobe outside the AEM runtime. There is no persistent HTTP connection between Author and Publish like in classic replication.
- Publish-pod auto-scaling: Publish pods can be added or removed dynamically. There is no per-instance agent to configure; all pods subscribe to the same distribution queue.
- Binary package import: Content is serialized into a distribution package (Apache Jackrabbit Vault format) on Author and deserialized on Publish by a dedicated service user. The active session on Publish at import time is
sling-distribution-importer. - Out-of-runtime execution: The serialization and pipeline handoff happens outside the normal AEM request cycle — there is no author user session in scope on the Publish side.
In AEM 6.5, Author pushes to Publish via HTTP. In AEM Cloud, Publish pulls packages from a queue. This inversion is why author session context is fundamentally unavailable on the Publish side during import.
3. Why jcr:createdBy Changes on Publish
JCR Protected Properties — The Specification
The JCR 2.0 specification (JSR-283) defines a category of protected properties. These properties are managed exclusively by the repository implementation and cannot be set, modified, or overridden by application code or imported content. The relevant protected properties in AEM are:
| Property | Type | Set By | Writable by App? |
|---|---|---|---|
| jcr:created | Date | Repository at node creation | No |
| jcr:createdBy | String | Repository at node creation (session user) | No |
| jcr:lastModified | Date | Repository on every save | No |
| jcr:lastModifiedBy | String | Repository on every save (session user) | No |
| cq:lastReplicatedBy | String | AEM replication layer (before distribution) | Yes |
| cq:lastReplicated | Date | AEM replication layer | Yes |
| custom:createdBy | String | Your application code | Custom |
What Happens During Package Import
When the distribution package arrives at a Publish pod, the distribution mechanism
applies it under a dedicated service session. That session calls
Node.addNode() (or equivalent) for each node in the package.
Because the node is being created for the first time in the Publish repository,
the JCR sets jcr:createdBy from the active session at that moment:
The distribution package does contain the original jcr:createdBy
value from Author. However, protected properties are explicitly excluded from the import
contract. The Apache Jackrabbit Oak importer will not apply them — it generates new
values from the active session. This is not a gap in AEM's implementation; it is
correct JCR-specification behaviour.
Why the Original User Context is Unavailable
In classic AEM 6.5 replication, there was at least a concept of a "replication user" whose credentials were explicitly configured in the agent. In AEM Cloud, the distribution pipeline is operated by Adobe infrastructure. By the time the package reaches the Publish pod:
- The original author's browser session has long since closed.
- There is no delegated user token or impersonation chain that spans Author → Pipeline → Publish.
- The only session available on Publish during import is the distribution mechanism's service session — there is no way to impersonate the original author across this boundary.
As of the time of writing, Adobe's public documentation does not call this out explicitly as a named behaviour. It is an emergent consequence of the JCR specification combined with the SCD architecture. Teams typically discover it by inspecting CRXDE on Publish or by debugging headless API responses.
4. Impact on Headless APIs and Reporting
JSON Exporters Returning Incorrect Creator
AEM's built-in Sling Model Exporter (and any custom JSON exporter backed by a
ValueMap read on Publish) will return a system user identity
if it reads jcr:createdBy directly from Publish. On Publish, content is
created by the distribution mechanism, so jcr:createdBy may reflect a
system user such as the distribution service — corrupting any front-end that renders
a byline, author card, or "written by" attribution.
Audit and Compliance Challenges
Organisations subject to content governance requirements (financial services, healthcare,
publishing) often need to trace which human created a piece of content for audit purposes.
If audit tooling queries Publish directly and reads jcr:createdBy, audit
trails will show only the system user — making it impossible to identify the responsible
author without cross-referencing the Author tier.
Personalisation and Ownership Workflows
Teams using jcr:createdBy to route content to an author's queue, build
personalised dashboards, or send notifications will find that Publish-side queries
return no meaningful results — everything appears owned by the distribution service.
Cross-Tier Reporting
Analytics pipelines or CMS reports that aggregate data from Publish instances will produce misleading creator distributions — thousands of pages attributed to a single system user.
5. Recommended Solution: Custom Authorship Property
The only reliable way to preserve original authorship across the distribution boundary is
to store it in a custom, non-protected JCR property on the
jcr:content node on Author, before the content is distributed.
Because this property is not protected, the distribution package preserves it and the
Publish repository applies it as-is.
Choosing the Right Property Name
Use a namespaced property to avoid collisions with AEM's built-in namespace. Common conventions:
custom:createdBy— explicit custom namespaceapp:originalAuthor— application-specific namespaceww:createdBy— vendor-prefixed (used in this guide)
Target JCR Structure
Immutability — Set Once, Never Overwrite
ww:createdBy should be written once at page creation
time and never changed again. It represents the original author.
ww:lastPublishedBy can be updated on every publish action.
Migration Strategy for Existing Content
For existing pages that don't yet have ww:createdBy, run a one-time
content migration script on Author that backfills from jcr:createdBy
(which is accurate on Author):
import com.day.cq.wcm.api.Page
import com.day.cq.wcm.api.PageManager
def pm = resourceResolver.adaptTo(PageManager)
def root = pm.getPage('/content/mysite')
root.listChildren(null, true).each { Page page ->
def content = page.contentResource
if (content == null) return
def props = content.adaptTo(ModifiableValueMap)
if (props == null) return
// Only backfill if not already set
if (!props.containsKey('ww:createdBy')) {
def original = props.get('jcr:createdBy', String)
if (original) {
props.put('ww:createdBy', original)
println "Backfilled: ${page.path} → ${original}"
}
}
}
resourceResolver.commit()
println "Migration complete."
Always run the migration script on the Author instance and then re-publish the affected pages. Running it on Publish directly would backfill a system user reflecting the distribution mechanism — exactly the wrong value.
6. Implementation Approaches
Option A — ResourceChangeListener (Preferred for AEM Cloud)
Listen for node-added events under /content on Author. When a
jcr:content node is first created, capture the creating user and write
the custom property immediately. This fires synchronously in the same request cycle as
page creation, so the author's session is readily available.
package com.acme.core.listeners;
import org.apache.sling.api.resource.*;
import org.apache.sling.spi.resource.provider.ResolveContext;
import org.osgi.service.component.annotations.*;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventHandler;
import org.apache.jackrabbit.api.security.user.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.jcr.Session;
import java.util.*;
import static org.apache.sling.api.resource.observation.ResourceChangeListener.*;
@Component(
service = { EventHandler.class },
immediate = true,
property = {
"event.topics=org/apache/sling/api/resource/Resource/ADDED",
"event.filter=(path=/content/**/jcr:content)"
}
)
public class PageAuthorshipListener implements EventHandler {
private static final Logger log = LoggerFactory.getLogger(PageAuthorshipListener.class);
private static final String CUSTOM_CREATED_BY = "ww:createdBy";
@Reference
private ResourceResolverFactory resolverFactory;
@Override
public void handleEvent(Event event) {
String path = (String) event.getProperty("path");
if (path == null || !path.endsWith("/jcr:content")) return;
Map authInfo = Collections.singletonMap(
ResourceResolverFactory.SUBSERVICE, "authorship-service"
);
try (ResourceResolver serviceResolver = resolverFactory.getServiceResourceResolver(authInfo)) {
Resource jcrContent = serviceResolver.getResource(path);
if (jcrContent == null) return;
ModifiableValueMap props = jcrContent.adaptTo(ModifiableValueMap.class);
if (props == null) return;
// Write only once — never overwrite the original author
if (!props.containsKey(CUSTOM_CREATED_BY)) {
// jcr:createdBy is accurate on Author at this point
String originalAuthor = props.get("jcr:createdBy", String.class);
if (originalAuthor != null && !originalAuthor.isBlank()) {
props.put(CUSTOM_CREATED_BY, originalAuthor);
serviceResolver.commit();
log.debug("Set {} = {} on {}", CUSTOM_CREATED_BY, originalAuthor, path);
}
}
} catch (Exception e) {
log.error("Failed to set authorship property on {}", path, e);
}
}
}
Service User Configuration
The listener uses a service user named authorship-service. Register it
in your OSGi configuration:
{
"user.mapping": [
"com.acme.core:authorship-service=[authorship-service]"
]
}
Create the system user authorship-service in the AEM User Manager and
grant it rep:write on /content. In AEM Cloud, add the
principal to your repository-initializer repoinit script:
create service user authorship-service
set ACL on /content
allow jcr:read,rep:write for authorship-service
end
Option B — Workflow Launcher on Page Creation
For teams that prefer workflow-based automation, configure a Workflow Launcher
that triggers on NODE_ADDED under /content matching
jcr:content nodes, and attach a workflow model with a single
custom process step:
package com.acme.core.workflow;
import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import org.apache.sling.api.resource.*;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component(
service = WorkflowProcess.class,
property = { "process.label=Set Custom Authorship Property" }
)
public class SetAuthorshipWorkflowProcess implements WorkflowProcess {
private static final Logger log = LoggerFactory.getLogger(SetAuthorshipWorkflowProcess.class);
@Override
public void execute(WorkItem workItem, WorkflowSession workflowSession,
MetaDataMap metaDataMap) throws WorkflowException {
String payloadPath = workItem.getWorkflowData().getPayload().toString();
try (ResourceResolver resolver = workflowSession.adaptTo(ResourceResolver.class)) {
// Workflow runs as the initiating user on Author — session is intact
String initiatorId = workItem.getWorkflow().getInitiator();
Resource jcrContent = resolver.getResource(payloadPath + "/jcr:content");
if (jcrContent == null) {
log.warn("jcr:content not found at {}", payloadPath);
return;
}
ModifiableValueMap props = jcrContent.adaptTo(ModifiableValueMap.class);
if (props == null) return;
// Set ww:createdBy only if not already present
if (!props.containsKey("ww:createdBy")) {
String originalAuthor = props.get("jcr:createdBy", initiatorId);
props.put("ww:createdBy", originalAuthor);
}
// Always update ww:lastPublishedBy when workflow fires on activation
props.put("ww:lastPublishedBy", initiatorId);
resolver.commit();
log.info("Authorship set: ww:createdBy={} on {}",
props.get("ww:createdBy", String.class), payloadPath);
} catch (Exception e) {
throw new WorkflowException("Could not set authorship property", e);
}
}
}
Option C — ReplicationAction Event (Pre-Distribution Hook)
If you want to stamp ww:lastPublishedBy on every publish action
without a full workflow, listen for the ReplicationAction OSGi event
which fires on Author just before the package is dispatched:
package com.acme.core.listeners;
import com.day.cq.replication.ReplicationAction;
import com.day.cq.replication.ReplicationActionType;
import org.apache.sling.api.resource.*;
import org.osgi.service.component.annotations.*;
import org.osgi.service.event.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
@Component(
service = EventHandler.class,
property = { "event.topics=" + ReplicationAction.EVENT_TOPIC }
)
public class PublishAuthorshipListener implements EventHandler {
private static final Logger log = LoggerFactory.getLogger(PublishAuthorshipListener.class);
@Reference
private ResourceResolverFactory resolverFactory;
@Override
public void handleEvent(Event event) {
ReplicationAction action = ReplicationAction.fromEvent(event);
if (action == null || action.getType() != ReplicationActionType.ACTIVATE) return;
String path = action.getPath();
String publishedBy = action.getUserId();
Map authInfo = Collections.singletonMap(
ResourceResolverFactory.SUBSERVICE, "authorship-service"
);
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(authInfo)) {
Resource jcrContent = resolver.getResource(path + "/jcr:content");
if (jcrContent == null) return;
ModifiableValueMap props = jcrContent.adaptTo(ModifiableValueMap.class);
if (props == null) return;
// Stamp original author only on first publish
if (!props.containsKey("ww:createdBy")) {
String creator = props.get("jcr:createdBy", publishedBy);
props.put("ww:createdBy", creator);
}
// Update publisher identity on every activation
props.put("ww:lastPublishedBy", publishedBy);
resolver.commit();
} catch (Exception e) {
log.error("Failed to record publish authorship for {}", path, e);
}
}
}
- Option A (ResourceChangeListener) — Best for capturing the true original creator at page-creation time. The author's identity is captured before any publish ever happens.
- Option B (Workflow) — Best when you already have a DAM/Page creation workflow. Easy to add as a step. Slightly delayed vs. the creation event.
- Option C (ReplicationAction) — Best for capturing publisher identity on every activation. Works alongside Option A for a complete picture.
7. Exposing Authorship in Sling Model and JSON Exporter
Update your Sling Model to read the custom property on Publish instead of relying on the JCR-managed one:
package com.acme.core.models;
import com.adobe.cq.export.json.ExporterConstants;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.models.annotations.*;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;
import org.apache.sling.models.annotations.injectorspecific.ChildResource;
import javax.annotation.PostConstruct;
@Model(
adaptables = Resource.class,
defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL,
resourceType = "acme/components/page",
exporterName = ExporterConstants.SLING_MODEL_EXPORTER_NAME,
extensions = ExporterConstants.SLING_MODEL_EXTENSION
)
public class PageMetaModel {
@ChildResource(name = "jcr:content")
private Resource jcrContent;
private String author;
private String lastPublishedBy;
private String lastPublished;
@PostConstruct
protected void init() {
if (jcrContent == null) return;
ValueMap props = jcrContent.getValueMap();
// ✓ Read custom property — reliable on both Author and Publish
author = props.get("ww:createdBy", String.class);
// Fallback chain: custom → cq:lastReplicatedBy → "Unknown"
if (author == null || author.isBlank()) {
author = props.get("cq:lastReplicatedBy",
props.get("jcr:createdBy", "Unknown"));
}
lastPublishedBy = props.get("ww:lastPublishedBy",
props.get("cq:lastReplicatedBy", "Unknown"));
lastPublished = props.get("cq:lastReplicated",
props.get("jcr:lastModified", ""));
}
/**
* JSON key: "author"
* Returns the original content creator, reliable on Publish.
*/
@Nonnull
public String getAuthor() {
return author != null ? author : "Unknown";
}
/**
* JSON key: "lastPublishedBy"
*/
@Nonnull
public String getLastPublishedBy() {
return lastPublishedBy != null ? lastPublishedBy : "Unknown";
}
/**
* JSON key: "lastPublished"
*/
@Nonnull
public String getLastPublished() {
return lastPublished != null ? lastPublished : "";
}
}
Expected JSON Output on Publish
{
"title": "Homepage",
"author": "[email protected]",
"lastPublishedBy": "[email protected]",
"lastPublished": "2026-04-13T10:30:00.000Z",
":type": "acme/components/page"
}
The author field now correctly reflects the original content creator
on Publish — regardless of which service user performed the distribution import.
8. Common Pitfalls
-
Reading
jcr:createdByon Publish in production APIs. This is the root mistake. On Publish, content is created by the distribution mechanism, sojcr:createdBymay reflect a system user such as the distribution service — not the original content author. Any headless endpoint, Sling Model, or GraphQL resolver that reads it directly from the Publish JCR will return the wrong identity. -
Running the backfill migration on Publish. The
jcr:createdByvalue on Publish is alreadysling-distribution-importer. Backfilling from it propagates the wrong value into your custom property. -
Treating
cq:lastReplicatedByas the creator. This property records who last published — not who originally created the page. The same person may have published someone else's content. -
Overwriting
ww:createdByon subsequent edits or publishes. The custom creator property must be immutable once set. Overwriting it on every save or publish defeats the purpose — it will end up reflecting the last editor, not the original author. - Assuming this is fixed by switching replication strategies. This behaviour is inherent to the JCR specification and applies to all content distribution mechanisms in AEM — classic replication, SCD, and the Content Transfer Tool. There is no configuration switch to make protected properties survive a cross-repository import.
- Forgetting to include the custom property in Content Transfer Tool migrations. When migrating from AEM 6.5 to Cloud, verify that custom authorship properties are included in your inclusion sets. They are application properties and need explicit inclusion if you're using path-based filters.
9. Key Takeaways — Do's and Don'ts
Not a bug. jcr:createdBy changing on Publish is correct JCR behaviour. The distribution service user creates the node on Publish — the repository records it accurately.
Protected properties cannot cross the distribution boundary. No AEM configuration or code can override jcr:createdBy on Publish. The specification prohibits it.
Custom properties survive. Non-protected custom properties (e.g., ww:createdBy) travel unchanged through the distribution package to Publish.
Set it on Author, read it anywhere. Capture the author identity at page-creation time on Author using a listener or workflow. It will be available on Publish after the first distribution.
Do
- Use
ww:createdBy(or equivalent) for authorship in APIs and UIs - Capture the author at page-creation time via
ResourceChangeListeneror workflow - Use
cq:lastReplicatedByfor "last published by" attribution - Run backfill migrations on Author only, then re-publish
- Treat
ww:createdByas immutable — write once, never overwrite - Document this behaviour for developers joining the project
Don't
- Read
jcr:createdBydirectly on Publish in any API or report - Assume this is a bug and raise an Adobe support case expecting a fix
- Run backfill scripts on Publish (wrong values will be captured)
- Confuse "publisher" (
cq:lastReplicatedBy) with "creator" - Overwrite
ww:createdByon subsequent edits or publishes - Rely on
jcr:lastModifiedByon Publish for audit trails
10. Frequently Asked Questions
jcr:createdBy value. What actually happens is that the Publish
instance treats the incoming content as a brand-new node being created for the first
time in its own JCR repository. The repository then sets jcr:createdBy
itself, using the active session at the time of import — which is always the
sling-distribution-importer service user. So the value is not "changed";
it is "set fresh" on Publish, independently of whatever value existed on Author.
This distinction matters: the Author value is not overwritten — it remains intact on
Author. The Publish instance simply never receives the Author value because protected
JCR properties are excluded from distribution packages by design.
jcr:createdBy is a protected property set by the repository at node-creation
time. On Publish, the node is created by the distribution importer service user, so the
repository records that user. This is not a defect in AEM's implementation.
replication-receiver) rather than sling-distribution-importer.
The root cause and solution are identical.
cq:lastReplicatedBy is a good built-in substitute for publisher
identity and requires no custom code. However, it records who last triggered the publish
action — not necessarily who originally created the page. If those are always the same
person in your workflow, it may be sufficient. For proper creator attribution, a custom
immutable property is more reliable.
jcr:createdBy will again reflect the distribution
mechanism's service user, since the node is created fresh in the Publish repository.
Your custom ww:createdBy property will survive because it travels
in the distribution package from Author each time. This is another argument in favour
of the custom property approach — it is resilient to full re-imports.
cq:Page nodes, the custom property approach on jcr:content
is the correct pattern. Content Fragments store author metadata differently and are
not directly comparable.
Related Topics
Building on AEM Cloud and Running Into Architecture Issues?
WegWorks specialises in AEM Cloud Service implementations, headless API design, and content distribution architecture. If your team is wrestling with metadata, audit workflows, or distribution behaviour, we can help.
Talk to an AEM Architect