Service Users & Repository Permissions in AEM — Secure Backend Repository Access
A practical developer and architect guide to AEM service users, subservice mappings, ResourceResolverFactory, least-privilege repository permissions, and secure backend repository access.
Service Users & Repository Permissions in AEM — Secure Backend Repository Access
In Chapter 26, we separated two questions that are easy to mix together:
- Who owns the
ResourceResolver? - Which identity is the repository operation running as?
A request already has a resolver associated with the incoming request context.
A scheduled job, workflow helper, event handler, or backend OSGi service may not have that request context at all.
But the code may still need to read or modify repository content.
That is where service users become part of the design.
The goal is not simply to make this call succeed:
resourceResolverFactory.getServiceResourceResolver(authInfo);
The real design question is:
What repository capability does this backend operation need, and what is the smallest identity that can perform it?
If a product synchronization service only needs to update content below
one application-owned subtree, it should not receive broad write access
across /content.
If a reporting service only reads DAM metadata, it should not receive delete permission.
The Java code and the repository permissions need to describe the same operation.
Why Backend Code Needs Its Own Repository Identity
Consider a servlet:
@Override
protected void doGet(
SlingHttpServletRequest request,
SlingHttpServletResponse response) {
ResourceResolver resolver =
request.getResourceResolver();
Resource resource =
resolver.getResource(path);
}
The request already provides a resolver.
Now consider a scheduled process:
@Component(service = Runnable.class)
public class ProductSyncJob
implements Runnable {
@Override
public void run() {
// No SlingHttpServletRequest exists here.
}
}
There is no incoming request from which the job can obtain:
request.getResourceResolver();
The backend operation needs an identity designed for its repository responsibility.
A service user provides that repository identity.
A Service User Is Not an OSGi Service
The names make this easy to confuse.
An OSGi service is a runtime-managed Java capability:
@Component(service = ProductService.class)
public class ProductServiceImpl
implements ProductService {
}
A service user is a repository identity used by backend code.
They solve different problems.
An OSGi service may use a service user when it needs repository access, but creating an OSGi service does not automatically create or assign a repository identity.
For example:
@Component(service = ProductRepositoryService.class)
public class ProductRepositoryServiceImpl
implements ProductRepositoryService {
@Reference
private ResourceResolverFactory
resourceResolverFactory;
}
The component is managed by OSGi.
The repository identity used by that component is a separate security decision.
Do Not Solve Backend Access With an Administrative Session
Older implementations sometimes solved repository access by obtaining a highly privileged session or resolver.
That makes the code convenient because permission failures disappear.
It also destroys the security boundary.
A product synchronization operation does not need permission to modify
unrelated DAM folders, workflow definitions, user data, or every site
below /content.
The correct question is not:
How can this code access everything it might need?
It is:
What exact repository paths and operations does this capability require?
That is the basis of least-privilege repository access.
The Subservice Is the Application-Level Name
Application code should not need to know the repository user name directly.
Instead, it asks for a resolver using a subservice name.
For example:
Map<String, Object> authInfo =
Collections.singletonMap(
ResourceResolverFactory.SUBSERVICE,
"product-writer"
);
try (ResourceResolver resolver =
resourceResolverFactory
.getServiceResourceResolver(
authInfo
)) {
// Repository operation
}
product-writer describes the application capability.
It is not the repository user itself.
The mapping between the application bundle, the subservice name, and the repository service user is configured separately.
That separation lets Java code depend on a meaningful capability name rather than hard-coding a repository principal.
The Mapping Connects Code to Repository Identity
Conceptually, the runtime needs to connect three pieces:
- The bundle requesting repository access
- The subservice requested by that bundle
- The repository service user that should be used
For example, application code may request:
product-writer
and the service-user mapping can associate that subservice for the application bundle with a repository service user such as:
myproject-product-writer
The Java implementation continues to ask for:
ResourceResolverFactory.SUBSERVICE,
"product-writer"
while the deployment configuration decides which repository identity satisfies that request.
This is useful because the application code describes the capability it needs, while repository identity remains an environment/deployment concern.
getServiceResourceResolver() Is the Runtime Boundary
Once the mapping exists, backend code can request the resolver:
Map<String, Object> authInfo =
Collections.singletonMap(
ResourceResolverFactory.SUBSERVICE,
"product-writer"
);
try (ResourceResolver resolver =
resourceResolverFactory
.getServiceResourceResolver(
authInfo
)) {
Resource product =
resolver.getResource(
"/content/myproject/products/product-1001"
);
// Repository work
}
This connects directly to the lifecycle rule from Chapter 26.
The service created the resolver, so the service owns it.
That is why try-with-resources is appropriate here.
The resolver should remain scoped to the repository operation instead of being stored in a long-lived OSGi service field.
Mapping and Permissions Are Two Different Layers
A valid service-user mapping does not automatically mean the operation can access every required path.
The mapping answers:
Which repository identity should this subservice use?
Repository permissions answer:
What can that identity actually do?
For example, the mapping may correctly resolve product-writer to the
intended service user.
The resolver can still fail to modify:
/content/myproject/products
if that identity has only read permission there.
This distinction is important during troubleshooting because a resolver-acquisition problem and a repository-permission problem are not the same failure.
Start Permissions From the Operation
Repository permissions should come from the capability being implemented.
A service that only reads product metadata should not receive write
access because another feature in the same application performs writes.
Likewise, a synchronization service that modifies one product subtree
does not automatically need write access across /content.
Read and write responsibilities can justify separate capability-oriented identities when their security boundaries differ. The goal is not one service user per Java class; it is a meaningful repository identity for each distinct backend capability.
Do Not Make One Service User for the Entire Application
A single identity such as:
myproject-service-user
may look simpler initially.
Over time it can become the identity used by:
- Product synchronization
- DAM processing
- Cleanup jobs
- Workflow helpers
- Content migration
- Reporting
- Integration callbacks
Now every feature receives the combined permissions required by all of them.
A better boundary is usually capability-oriented.
Examples might include:
product-reader
product-writer
asset-metadata-reader
content-cleanup
This does not mean creating a separate service user for every Java class.
The boundary should represent a repository capability with a meaningful permission set.
Where Repo Init Fits
Repository identities and permissions need to be reproducible across environments.
They should not depend on someone manually creating a user and clicking permissions in one environment.
In modern AEM projects, Repo Init is commonly used to provision service users and repository permissions as deployment-managed configuration.
A simplified example may look like:
create service user myproject-product-writer
set ACL for myproject-product-writer
allow jcr:read on /content/myproject/products
allow rep:write on /content/myproject/products
end
The exact permissions should come from the operation being implemented.
The important architectural point is that repository access becomes part of the deployable application configuration rather than undocumented manual setup.
We will build the complete service-user configuration and repository example in the next sections.
Building a Service User End to End
A useful way to understand the complete setup is to start from one repository operation and follow it through every layer.
Assume our application has a backend service that updates synchronization metadata for product content below:
/content/myproject/products
The service needs to read product resources and update a small set of properties.
That gives us four things to configure:
- A service user
- Repository permissions for that service user
- A subservice mapping for the application bundle
- Java code that requests the mapped subservice
1. Create the Service User
The repository identity should describe the capability it represents.
For this example:
myproject-product-writer
Using Repo Init:
create service user myproject-product-writer
This creates a service user intended for backend repository access.
The name is deliberately more specific than something like:
myproject-service-user
because the permission boundary belongs to product-writing behavior, not to every backend feature in the application.
2. Grant Only the Required Repository Permissions
The service needs repository access below:
/content/myproject/products
A Repo Init configuration can assign the required privileges there.
For example:
set ACL for myproject-product-writer
allow jcr:read on /content/myproject/products
allow rep:write on /content/myproject/products
end
The exact privileges need to match the operation.
If the service only updates existing properties, its permission requirement may be narrower than a service that creates and deletes resources.
That is why I do not start permission design by copying an ACL from another service user.
I start from the repository operations the feature actually performs.
3. Map the Subservice to the Service User
The Java code should request a capability name:
product-writer
The Sling service-user mapping connects that subservice to:
myproject-product-writer
A project configuration may contain a mapping conceptually like:
com.myproject.core:product-writer=[myproject-product-writer]
Here:
com.myproject.core
identifies the bundle or bundle symbolic name used by the mapping,
product-writer
is the subservice requested by the Java code,
and:
myproject-product-writer
is the repository service user or principal associated with that capability.
The exact configuration format depends on how the project manages the Sling Service User Mapper configuration, but the architectural relationship stays the same:
bundle + subservice → repository identity
4. Request the Resolver From Java
Now the application service can request that capability:
@Component(service = ProductRepositoryService.class)
public class ProductRepositoryServiceImpl
implements ProductRepositoryService {
private static final String SUBSERVICE =
"product-writer";
@Reference
private ResourceResolverFactory
resourceResolverFactory;
@Override
public void markSynchronized(String productPath)
throws ProductRepositoryException {
Map<String, Object> authInfo =
Collections.singletonMap(
ResourceResolverFactory.SUBSERVICE,
SUBSERVICE
);
try (ResourceResolver resolver =
resourceResolverFactory
.getServiceResourceResolver(
authInfo
)) {
Resource product =
resolver.getResource(productPath);
if (product == null) {
throw new ProductRepositoryException(
"Product resource was not found"
);
}
ModifiableValueMap properties =
product.adaptTo(
ModifiableValueMap.class
);
if (properties == null) {
throw new ProductRepositoryException(
"Product resource is not modifiable"
);
}
properties.put(
"syncStatus",
"completed"
);
resolver.commit();
} catch (LoginException
| PersistenceException e) {
throw new ProductRepositoryException(
"Unable to update product synchronization status",
e
);
}
}
}
The Java code does not log in as:
myproject-product-writer
directly.
It asks for:
product-writer
and the runtime mapping decides which repository identity satisfies that request.
That keeps the application contract and repository identity separated.
What Happens When getServiceResourceResolver() Runs
This call looks small:
resourceResolverFactory
.getServiceResourceResolver(authInfo);
but several pieces must line up for it to succeed.
The calling bundle requests the subservice:
product-writer
The Sling service-user mapping must contain a valid mapping for that bundle and subservice.
The mapped service identity must exist.
Once the resolver is obtained, the repository permissions assigned to that identity determine which resources the operation can read or modify.
This gives us two different failure stages.
Resolver Acquisition Failure
The application may fail while obtaining the resolver.
For example:
try {
ResourceResolver resolver =
resourceResolverFactory
.getServiceResourceResolver(
authInfo
);
} catch (LoginException e) {
// Resolver could not be obtained.
}
At this point, I investigate the service-user setup itself:
- Is the requested subservice name correct?
- Is the mapping configured for the correct bundle?
- Does the mapped service identity exist?
- Is the configuration deployed in this environment?
This is different from a repository permission failure.
Repository Authorization Failure
The resolver may be created successfully but still lack permission for the target operation.
For example:
Resource resource =
resolver.getResource(
"/content/myproject/products/product-1001"
);
or:
resolver.commit();
may not behave as expected if the service identity does not have the required repository privileges.
In that case, changing the subservice mapping may not be the answer.
The mapping may already be correct.
The problem may be the permissions assigned to the mapped identity.
Keeping those two layers separate makes troubleshooting much faster.
Do Not Fall Back to a More Privileged Resolver
One of the worst responses to a service-user failure is to make the code silently try another, more privileged identity.
For example, the application should not treat this:
product-writer mapping failed
as a reason to obtain some general-purpose resolver with broader repository access.
A missing or incorrect security configuration should fail visibly.
Otherwise a deployment mistake can quietly change the security boundary of the application.
If product-writer is the capability required by the feature, then that
capability needs to be configured correctly.
Keep the Subservice Name Close to the Capability
A subservice name should help someone understand why the resolver exists.
This is clearer:
private static final String SUBSERVICE =
"product-writer";
than:
private static final String SUBSERVICE =
"service-user";
or:
private static final String SUBSERVICE =
"backend";
Generic names tend to become shared by unrelated features.
Once that happens, the permissions behind the subservice usually grow with every new use case.
The name itself does not enforce security, but a capability-oriented name makes poor reuse easier to notice during design and code review.
Repo Init Should Describe Repository Security as Code
Service-user configuration should not depend on someone manually recreating users and permissions in every environment.
Repo Init lets the repository identity and ACL setup travel with the application configuration, making the intended security model visible in source control and reproducible during deployment.
The important point is not simply that Repo Init can create the user. The configured paths and privileges still need to match the repository operations owned by that capability.
Path Scope Is Part of Least Privilege
Least privilege is not only about choosing privileges such as jcr:read
or write-related privileges. The repository path matters just as much.
Compare:
allow rep:write on /content
with:
allow rep:write on /content/myproject/products
Both may allow a product feature to work, but they define very different security boundaries.
The same applies to reads. A backend integration should not automatically receive broad repository visibility simply because it needs to read one application subtree.
For every capability, I want to know both:
What does this identity need to change?
and:
What does this identity need to see?
The detailed ACL design later in this chapter applies those questions to read, write, create, and delete operations.
Service Users Do Not Authorize HTTP Callers
This boundary is important when a servlet uses an OSGi service that obtains a service resolver.
Consider a servlet that delegates repository work to an OSGi service which obtains its own service resolver.
The service user answers:
Which repository identity performs the backend repository operation?
It does not answer:
Was the HTTP caller allowed to trigger that operation?
Those are separate security checks.
A servlet cannot become safe simply because the repository work runs through a narrowly permissioned service user.
The HTTP layer may still need to validate:
- Who can call the endpoint
- What input is accepted
- Which business operation the caller is allowed to trigger
- Whether request protections such as CSRF handling are required
The service user limits what the backend repository identity can do.
It does not replace endpoint authorization.
Passing a Request Resolver vs Obtaining a Service Resolver
Not every OSGi service that accesses the repository needs to obtain a service resolver.
Sometimes the intended design is:
productService.getProduct(
request.getResourceResolver(),
productPath
);
The service then works in the caller's repository context.
That can be correct when repository access should follow the current request identity.
In other cases:
productSyncService.synchronize(productId);
represents a backend capability that should run under its own controlled repository identity.
Then the service can obtain its own resolver through a mapped subservice.
The important part is that the identity decision is deliberate.
Moving repository logic into an OSGi service does not automatically mean it should switch to a service user.
Do Not Pass Service Resolvers Around Without an Ownership Contract
Suppose one service obtains a resolver:
try (ResourceResolver resolver =
getServiceResolver()) {
anotherService.update(resolver, path);
}
This can be valid when the first service intentionally owns the complete repository operation.
But anotherService must not decide to close that resolver.
The ownership rule from Chapter 26 still applies.
A service user changes the identity used to obtain the resolver.
It does not change resolver lifecycle rules.
If the resolver is caller-owned, the called service uses it but does not close it.
If a service obtains its own resolver, that service owns the lifecycle.
Designing Least-Privilege Permissions
For each backend capability, translate least privilege into concrete repository operations:
- Which paths does it read?
- Which paths does it modify?
- Does it create or delete resources?
- Does it need referenced content from another subtree?
- Which repository areas are outside its responsibility?
Those answers give us the permission model.
Start From the Use Case, Not From an Existing ACL
Suppose a product synchronization service performs these operations:
- Read product resources below
/content/myproject/products - Update synchronization properties on those resources
- Read integration configuration from an application-owned configuration location
- Never delete product content
That requirement should drive the repository permissions.
It would be a mistake to copy the ACL from a migration utility that can
create and delete large content trees simply because both features
happen to work below /content.
Two services can use the same repository API while needing very different permissions.
Separate Read-Only and Write Capabilities When the Responsibilities Differ
Consider two backend features.
A product export process reads content:
/content/myproject/products
A synchronization process updates content under the same subtree.
The export process may need a read-oriented identity such as:
product-reader
while the synchronization process may need:
product-writer
This does not mean every read and write method needs its own service user.
The useful boundary is the application capability.
If two operations have the same repository responsibility and security boundary, sharing an identity can be reasonable.
If their responsibilities are materially different, combining them only to reduce configuration usually creates a broader permission set than necessary.
Write Access Should Follow the Actual Write Location
Suppose a service reads product content from:
/content/myproject/products
but writes generated synchronization state under:
/var/myproject/product-sync
The permission model should reflect those two responsibilities separately.
Conceptually:
set ACL for myproject-product-sync
allow jcr:read on /content/myproject/products
allow jcr:read,rep:write on /var/myproject/product-sync
end
The service does not need write access to the product content simply because it reads products during processing.
This is one of the easiest ways permissions become broader than the feature requires: the read path and write path are treated as though they must have the same privileges.
Delete Permission Deserves Extra Attention
A service that updates metadata and a service that deletes content do not have the same risk profile.
If deletion is part of the capability, the path boundary should be especially clear.
For example, a cleanup service may legitimately own generated resources under:
/var/myproject/generated-data
That does not justify delete capability across:
/content
or:
/content/dam
The repository identity should limit the damage possible if the Java implementation contains a path bug or receives unexpected input.
Application validation still matters, but repository permissions provide another boundary.
Permissions Should Match Content Ownership
A useful architecture question is:
Which part of the repository does this application capability actually own?
If a service manages application-generated data below:
/var/myproject
the ownership boundary is relatively clear.
If it modifies author-managed content below:
/content/myproject
the design needs more care.
A backend service that can write author-managed content can affect authoring behavior, publishing, workflows, references, and content governance.
The fact that a service user can technically receive write permission does not mean the application should automatically do so.
Repository permission design should follow content ownership as well as Java requirements.
Repo Init Permission Design
Repo Init gives us a way to make repository identities and ACLs repeatable, but it does not decide whether those ACLs are well designed.
The design still comes first.
Consider a read-only service:
create service user myproject-product-reader
set ACL for myproject-product-reader
allow jcr:read on /content/myproject/products
end
Now compare it with a service that owns generated application data:
create service user myproject-product-sync
set ACL for myproject-product-sync
allow jcr:read on /content/myproject/products
allow jcr:read,rep:write on /var/myproject/product-sync
end
The second identity has broader capabilities, but only where its responsibility requires them.
Avoid Broad Parent-Path Permissions for Convenience
This is easy to write:
allow jcr:read,rep:write on /content
It is also much broader than most application services need.
If the actual operation is limited to:
/content/myproject/products
granting write access at /content makes unrelated content part of the
service's security boundary.
That creates future risk because new sites and applications may later be
added below /content, while the old service user silently retains
access to them.
The narrower path is usually easier to reason about and easier to review.
Avoid Using rep:write Without Understanding the Operation
rep:write is convenient because it represents a collection of
write-related privileges.
That convenience should not replace permission analysis.
A feature that only updates existing properties may not have the same privilege requirement as one that creates and removes child resources.
For a production ACL, I want the privilege set to come from the operations the implementation performs and the repository structures it owns.
If a narrower privilege set is appropriate for the operation, use it rather than treating broad write permission as the default.
The exact ACL should be validated against the repository behavior of the feature, not copied mechanically from a sample.
Service User Mapping Should Be Specific
The mapping is another place where accidental reuse can widen a security boundary.
Suppose the application has:
product-reader
and:
product-writer
If both are mapped to the same broadly permissioned repository identity, the names suggest separation but the runtime security model does not actually provide it.
For example:
product-reader -> myproject-product-service
product-writer -> myproject-product-service
means both capabilities ultimately run with the permissions of:
myproject-product-service
If that identity has write permission, the read-oriented subservice does not create a meaningful repository boundary.
The mapping and ACL design need to agree with the capability names used by the application.
Do Not Put Repository User Names Into Business Logic
Application code should not spread repository principal names throughout services:
String serviceUser =
"myproject-product-writer";
and then build application behavior around that name.
The Java layer should depend on the subservice capability:
private static final String SUBSERVICE =
"product-writer";
The mapping owns the connection to the repository identity.
This keeps repository-user naming and provisioning outside business logic.
Common Service User Problems in AEM
The same Java call can fail for several configuration reasons. I keep these failure modes separate because they point to different fixes.
Symptom First thing to verify
LoginException while obtaining Subservice name, bundle mapping,
the resolver mapped service identity, deployed
configuration
Resolver works but target resource ACL at the actual repository path is inaccessible
Read works but write fails Write privileges for the mapped identity
Local works but another environment Repo Init and mapping deployment; fails avoid relying on manual local changes
Permissions look correct but Whether the code is actually using operation still fails a different path than the ACL covers
Feature always works because Review for excessive access, not identity is broadly privileged only denied access
A working feature is not proof that its service-user design is correct. Excessive access is also a security design problem.
Troubleshooting getServiceResourceResolver()
When a service resolver cannot be obtained, I troubleshoot from the application capability outward.
First, verify the exact subservice requested by Java:
ResourceResolverFactory.SUBSERVICE,
"product-writer"
Then verify that the mapping is defined for the bundle making that call.
Next, verify that the mapped repository service identity exists.
If resolver acquisition succeeds, move to repository authorization.
Check the exact path the code accesses and whether the service identity has the privileges required for that operation.
For writes, separate:
- Resolving the resource
- Adapting it for modification
- Performing the change
- Persisting the change
A failure at resolver acquisition is different from a failure during repository persistence.
That distinction prevents permission troubleshooting from turning into random configuration changes.
A Practical Permission Review
Suppose we see this implementation:
public void updateStatus(String path)
throws LoginException,
PersistenceException {
Map<String, Object> authInfo =
Collections.singletonMap(
ResourceResolverFactory.SUBSERVICE,
"content-service"
);
try (ResourceResolver resolver =
resourceResolverFactory
.getServiceResourceResolver(
authInfo
)) {
Resource resource =
resolver.getResource(path);
if (resource == null) {
return;
}
ModifiableValueMap properties =
resource.adaptTo(
ModifiableValueMap.class
);
if (properties != null) {
properties.put(
"status",
"processed"
);
resolver.commit();
}
}
}
The Java code itself is small.
The architecture review should ask more than whether it compiles.
What Does content-service Mean?
The name is generic.
Can this identity update product content?
DAM metadata?
Experience Fragments?
Configuration?
Generated data?
If the answer is unclear, the capability boundary is unclear.
A name such as:
product-status-writer
may communicate the responsibility better.
Why Can the Caller Supply Any Path?
The method accepts:
updateStatus(String path)
If this service only exists for product content, a stronger contract may accept a product identifier and derive the repository location internally.
At minimum, the implementation needs to ensure the requested path belongs to the repository area owned by the capability.
Repository permissions should reinforce that same boundary.
What Permissions Does the Service Actually Need?
If the method only updates status properties below:
/content/myproject/products
its repository identity should not receive write access to unrelated content.
The permission model should match the implementation's intended scope,
not the generic String path parameter.
The Java contract, path handling, mapping, and ACL need to tell the same story.
Architect Perspective — Repository Identity Is Part of the Service Boundary
A backend service is not fully designed when its Java interface is complete.
If that service touches the repository, its identity and permissions are part of the architecture.
For each repository-backed capability, I want the design to make these decisions visible:
- Does the operation use the caller's repository context or its own backend identity?
- If it uses a backend identity, what is the subservice capability?
- Which repository service user is mapped to it?
- Which paths can that identity read?
- Which paths can it modify?
- Can it create or delete content?
- Who owns the resolver lifecycle?
- Is the repository security configuration reproducible through deployment?
This keeps repository security connected to the feature rather than treating permissions as an environment problem discovered after deployment.
A service user is not simply a technical workaround for obtaining a
ResourceResolver.
It is the repository identity of a backend capability.
That identity should be as deliberate as the Java API that uses it.
Summary
Service users give backend AEM code a controlled repository identity when there is no appropriate request context.
Application code requests a named subservice through
ResourceResolverFactory. Service-user mapping connects that capability
to a repository identity, and repository permissions determine what that
identity can actually access.
Those layers should remain separate:
- The subservice describes the application capability
- The mapping selects the repository identity
- The ACL defines the repository boundary
- The Java code owns the resolver lifecycle when it obtains the resolver
Least privilege means more than avoiding administrative access. It means matching both repository paths and privileges to the actual responsibility of the feature.
Service users also do not replace HTTP authorization. A narrowly permissioned backend identity limits repository access, but the request layer still decides who is allowed to trigger the operation.
What's Next
Chapter 28 — OSGi Configuration Architecture
The next chapter moves from repository identity to runtime configuration: how AEM services receive environment-specific values, how configuration should be scoped, and how configuration design affects service boundaries and deployment behavior.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.