Adobe AEM

AEM Security: Users, Groups, ACLs & Permissions — The Complete Guide

27 min read

A practical guide to AEM security — authentication vs authorization, users, groups and principals, how Oak evaluates ACLs, privileges and restrictions, service users and repoinit, principal-based authorization, Closed User Groups with Dispatcher permission-sensitive caching, IMS and SAML, and a hardening checklist for AEM as a Cloud Service and 6.5. Includes code, a cheat sheet, best practices, and do's & don'ts.

AEMSecurityACLOakRepoinitCloud Service
AEM Security: Users, Groups, ACLs & Permissions — The Complete Guide

Every request that reaches AEM ends in the same question: is this subject allowed to do this, on this node? Your author's edit, a code-driven workflow step, an anonymous visitor on publish, and a logged-in member reading gated content all go through one mechanism, the Jackrabbit Oak access control model. When permissions are designed well you never notice them. When they aren't, you get authors who can't publish, service code that fails with AccessDeniedException only in production, gated pages leaking through a cache, or an admin-level resolver sitting in a servlet.

This guide builds the picture from the bottom up: authentication vs authorization, principals, how Oak stores and evaluates ACLs, privileges and restrictions, service users and repoinit, principal-based authorization, Closed User Groups and Dispatcher caching, IMS and SAML, and a hardening checklist. Throughout, it flags where AEM as a Cloud Service and AEM 6.5 differ.

It builds on the JCR & Oak guide (where the ACL nodes actually live), the Apache Sling guide (resource resolvers), and the OSGi guide (the configs that drive repoinit and service mapping). For the perimeter side, keep the Dispatcher guide and Web Security Headers guide open.

Authentication vs authorization

The two words get used interchangeably, but in AEM they are separate layers, handled by separate code:

AuthenticationAuthorization
QuestionWho are you?What may you do here?
Handled bySling authentication handlers (login token, SAML, IMS, basic auth)Oak access control and permission evaluation
OutputA JCR session for a subject with a set of principalsAllow or deny for each read, write, or admin operation
Configured withAuthentication handler OSGi configs, login pagesACLs, CUG policies, principal policies

The Sling authenticator picks an authentication handler based on the request path, the handler establishes identity, and the result is a repository session. If no credentials are present on publish, you get the anonymous session. From that point on, every repository access (resolving a resource, running a query, reading a property in HTL) is checked by Oak against that session's principals. If Oak says you can't read a node, Sling can't resolve it, and you usually get a 404 rather than a 403. So a resource that works as admin and 404s for a real user is almost always a read-permission problem, not a missing node.

Users, groups, and principals

Oak's user management stores authorizables in the repository:

  • Users (rep:User) live under /home/users. They're the identities people log in as.
  • Groups (rep:Group) live under /home/groups. They collect users and other groups (groups can nest).
  • System users (rep:SystemUser) live under /home/users/system. They have no password and can't log in over HTTP. They exist only to be used by code through service authentication.

A principal is the identity access control works with. A logged-in session holds the user's own principal plus one for every group they belong to, directly or through nesting. ACL entries reference principal names, which is why granting to a group works for every member.

The everyone principal

Every subject carries everyone, including the anonymous session, and Adobe says not to modify or delete the group. On publish, an allow granted to everyone is granted to the entire internet, and a deny for everyone applies to everybody (the evaluation rules below decide when a group allow overrides it).

Built-in users and groups

These are the defaults you'll meet on an AEM 6.5 instance, as described in Adobe's User Administration and Security docs:

AuthorizableTypeWhat it's for
adminUserFull system administrator. On 6.5, change its password immediately
anonymousUserIdentity for unauthenticated requests. Don't disable it; restrict it
administratorsGroupFull admin rights for all members
everyoneGroupEvery user is a member; default rights for all
contributorGroupBasic content write privileges
content-authorsGroupContent editing (read, modify, create, delete)
dam-usersGroupReference group for AEM Assets users
workflow-usersGroupCan participate in workflows
user-administratorsGroupCan create and manage users and groups

Tip: Treat the built-in groups as building blocks, not as your permission model. Create project groups (for example mysite-authors), put the ACLs on those, and nest built-in or IMS groups into them.

How Oak access control works

Where ACLs live: rep:policy

In Oak's default (resource-based) model, access control is stored on the node it protects. A node that has an ACL gets the rep:AccessControllable mixin and a child node named rep:policy (type rep:ACL). Each child of that policy is one access control entry (ACE), and their order matters (more on that below):

/content/mysite
├── rep:policy                    (rep:ACL)
│   ├── deny                      (rep:DenyACE)
│   │     rep:principalName = "everyone"
│   │     rep:privileges    = [jcr:read]
│   ├── allow                     (rep:GrantACE)
│   │     rep:principalName = "mysite-authors"
│   │     rep:privileges    = [jcr:read, rep:write]
│   └── allow1                    (rep:GrantACE)
│         rep:principalName = "mysite-authors"
│         rep:privileges    = [crx:replicate]
│         rep:restrictions  (rep:Restrictions)
│               rep:glob = "/en/*"
└── en/ ...

Repository-level privileges (such as namespace registration) live in rep:repoPolicy at the root. Policy nodes are protected: you change them through the JCR AccessControlManager API, the permission UIs, or repoinit, never with setProperty.

Privileges

A privilege is the unit you allow or deny. Several of them are aggregates of finer-grained ones:

PrivilegeMeaning
jcr:readRead nodes and properties. Aggregate of rep:readNodes and rep:readProperties
jcr:modifyPropertiesAggregate of rep:addProperties, rep:alterProperties, rep:removeProperties
jcr:addChildNodesCreate child nodes
jcr:removeNode / jcr:removeChildNodesDelete the node / delete its children. Removing a node needs jcr:removeNode on it and jcr:removeChildNodes on its parent
jcr:writeJCR's "simple write" aggregate (modify properties, add, and remove nodes)
rep:writeJackrabbit's "full write" aggregate: jcr:write plus jcr:nodeTypeManagement
jcr:readAccessControl / jcr:modifyAccessControlRead / edit ACLs
jcr:versionManagement, jcr:lockManagementCheck in/out and restore versions / lock nodes
rep:userManagementCreate and modify users and groups
crx:replicateAEM's replicate (publish) permission
jcr:allEverything

crx:replicate needs a note. It's stored and evaluated like any other privilege, but the JCR doesn't enforce it; AEM's replication code checks it.

The UI's Permissions matrix (Read, Modify, Create, Delete, Read ACL, Edit ACL, Replicate) is a friendly mapping onto these privileges. When you need precision, such as "can create pages but not delete them", work in privileges.

Restrictions

A restriction narrows where an ACE applies below the node it's defined on. Oak's default restriction provider supports:

RestrictionEffectOak version
rep:globSingle path pattern with * wildcards, relative to the ACL's node1.0
rep:ntNamesOnly nodes of the given primary node types1.0
rep:prefixesOnly items whose name has one of the given namespace prefixes1.0
rep:itemNamesOnly nodes or properties with the given names1.3.8
rep:currentOnly the target node (and optionally named properties), not its subtree1.42.0
rep:globsMulti-valued rep:glob1.44.0
rep:subtreesOnly the given subtrees1.44.0

rep:glob is the one you'll use most, and its semantics are subtle. According to the Oak documentation, when the ACE sits on /content/mysite:

rep:glob valueMatches
(absent)The node and its entire subtree
"" (empty string)Only /content/mysite itself
/catThe child cat and its descendants
/cat/Only the descendants of cat (not cat itself)
*The node, same-prefix siblings (/content/mysite2), and their descendants
/*catDescendants whose path ends with cat

Important: The empty-string glob is the classic trick for "let a user list this folder but not read its contents": allow jcr:read with rep:glob="" on the folder. Absent and empty are not the same. An ACE with no restriction applies to the whole subtree.

Evaluation order and inheritance

This is where most permission bugs come from. The Oak permission evaluation documentation defines the rules, and they're worth memorizing:

  1. Permissions inherit down the tree. An ACE on /content applies to /content/mysite/en unless something closer overrides it.
  2. Entries on the target node beat inherited entries. Oak walks from the node up toward the root. An entry closer to the node wins over one further up.
  3. Within one ACL, later entries beat earlier ones. Entries are evaluated in reverse order, so order in rep:policy matters.
  4. User principals always beat group principals, regardless of their order or where they sit in the hierarchy. All entries for the user's own principal are evaluated before any group entries.
  5. Group principals are evaluated together. Oak doesn't rank groups against each other. A user who is in two groups, one allowed and one denied at the same node, gets whichever entry comes later in that ACL. That's why Adobe warns that deny statements from one group can cancel allows from another in ways that are hard to predict.

A worked example: everyone is denied jcr:read on /content/intranet, and employees is allowed jcr:read on /content/intranet/news. An employee can read news because the closer entry wins. They can't read /content/intranet/hr, because the only applicable entry is the inherited deny. If you add deny jcr:read for employees directly on /content/intranet/news/drafts, that closer deny wins for drafts.

Tip: Adobe's standing advice is to prefer allow and use deny sparingly. A model built as "deny broad, allow narrow for groups" is predictable. A model with scattered group-level denies, where users belong to several groups, is not.

The permission UIs and "Effective Permissions"

Two places let you inspect and manage permissions without CRXDE:

  • Tools → Security → Permissions (both Cloud Service and 6.5). This is the principal view: pick a user or group and see every path where it has explicit entries, with privileges and restrictions. Add ACE lets you choose a path, one or more privileges from the jcr, rep, or crx namespaces, allow or deny, and restrictions such as rep:glob. For each node it also shows local ACLs and the effective ACLs inherited from every parent up to the root.
  • Page Properties → Permissions tab (Sites console). Here you can Add Permissions, Edit Closed User Group, and open Effective Permissions, which shows what each principal can actually do on that page once inheritance is taken into account.

When an author says "I can't publish this page", open Effective Permissions before you change anything. The local ACL rarely tells the whole story.

Note: On AEM as a Cloud Service production, you manage content permissions this way at runtime, but anything your application depends on (service users, ACLs that code relies on, groups that workflows assign to) should be deployed as code through repoinit. Changes made by hand in the UI don't exist in your other environments.

Service users

Why loginAdministrative is deprecated

Backend code (schedulers, event handlers, jobs) often needs repository access without a user's session. The old way was ResourceResolverFactory.getAdministrativeResourceResolver() or SlingRepository.loginAdministrative(). Both give full admin rights. Apache Sling deprecated them because they "allow for much too broad access". Adobe's code quality rules flag them, and the replacement is service authentication:

DeprecatedReplacement
ResourceResolverFactory.getAdministrativeResourceResolver(...)ResourceResolverFactory.getServiceResourceResolver(...)
SlingRepository.loginAdministrative(...)SlingRepository.loginService(subServiceName, workspace)

How service mapping works

Service authentication has three parts: a system user with exactly the permissions the task needs, a mapping that says "when bundle X asks for subservice Y, use these principals", and code that asks for a resolver by subservice name. The mapping is configured with factory configurations of org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended. The service name defaults to the bundle symbolic name of the bundle making the call. Sling supports two mapping formats:

# Principal-name format (recommended)
<bundle-symbolic-name>:<subservice>=[principal-one,principal-two]

# User-ID format (older)
<bundle-symbolic-name>:<subservice>=user-id

Adobe recommends the bracketed principal-name format "as of AEM 6.4". It's faster, and it gives you exact control over effective permissions because the mapped principals are used directly: group memberships aren't resolved. That's also why Adobe's service-user best practices say never put service users in groups.

ui.config/.../osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-mysite.cfg.json:

{
  "user.mapping": [
    "com.mysite.core:content-reader=[mysite-content-reader-service]",
    "com.mysite.core:form-writer=[mysite-form-writer-service]"
  ]
}

Using it in code

@Component(service = ContentStats.class)
public class ContentStats {

    private static final String SUBSERVICE = "content-reader";

    @Reference
    private ResourceResolverFactory resolverFactory;

    public int countPages(String rootPath) {
        Map<String, Object> authInfo =
                Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SUBSERVICE);

        try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(authInfo)) {
            Resource root = resolver.getResource(rootPath);
            if (root == null) {
                return 0; // missing OR not readable by the service user
            }
            // ... walk / query ...
            return 1;
        } catch (LoginException e) {
            // No mapping, or the mapped system user doesn't exist yet
            throw new IllegalStateException("Service login failed for " + SUBSERVICE, e);
        }
    }
}

Three habits matter here. Always close the resolver (try-with-resources), because leaked resolvers leak sessions. Treat a LoginException as a deployment problem: the mapping or the user is missing. And remember that a null resource can mean "no read permission", not only "doesn't exist". The unit testing guide shows how to test it.

Important: Don't use a service resolver to do work on behalf of a request user when the user's own resolver would do. request.getResourceResolver() respects the caller's permissions; a broad service resolver in the same servlet is a privilege-escalation path.

Repoinit: permissions as code

Repository initialization (repoinit) is Apache Sling's small declarative language for creating service users, groups, paths, and ACLs. Scripts are delivered as factory configurations of org.apache.sling.jcr.repoinit.RepositoryInitializer, with the script text in the scripts property (a references property can point to external script URLs). It's the documented way to create service users, groups, and ACLs on Cloud Service, and best practice on 6.5 too. Place the configs in ui.config with a descriptive suffix, for example org.apache.sling.jcr.repoinit.RepositoryInitializer~mysite.cfg.json. The usual config.author / config.publish folders scope scripts to a tier.

The language

The statements you'll use most, taken from the Sling repoinit documentation:

# Service users (system users, no password)
create service user mysite-legacy-reader-service with path system/mysite

# Groups and membership
create group mysite-authors
add mysite-editor-lead to group mysite-authors

# Paths / structure
create path (sling:OrderedFolder) /content/dam/mysite
create path /conf/mysite/settings

# Resource-based ACLs, grouped by path ...
set ACL on /content/mysite
    allow jcr:read for mysite-legacy-reader-service
    allow jcr:read,rep:write for mysite-authors
end

# ... or grouped by principal
set ACL for mysite-authors
    allow crx:replicate on /content/mysite
    allow jcr:modifyProperties on /content/mysite restriction(rep:itemNames,jcr:title,jcr:description)
end

# Properties
set properties on /conf/mysite/settings
    default sling:resourceType{String} to mysite/components/config
end

A few details are worth knowing:

  • Restrictions are written restriction(name,value1,value2) after the path, as in the rep:itemNames line above, and a statement can carry more than one.
  • create path vs ensure nodes. Newer repoinit versions deprecate create path in favour of ensure nodes, because create path never adjusts the types of existing nodes. Adobe's docs still use create path, and it still works. Check what your AEM version's repoinit parser supports before switching.
  • Cleanup: delete ACL for, delete principal ACL for, and disable service user <name> : "reason".
  • Merging. By default set ACL merges into existing entries. (ACLOptions=mergePreserve) and similar options change this. Adobe notes that the .config format doesn't support every directive, including ACLOptions, so use .cfg.json when you need them.

A complete .cfg.json

The Sling RepositoryInitializerFactory parses each entry of the scripts array as its own script. The portable way is to keep each complete statement or block (from set ACL to end) in a single string with \n line breaks:

ui.config/.../osgiconfig/config/org.apache.sling.jcr.repoinit.RepositoryInitializer~mysite.cfg.json:

{
  "scripts": [
    "create service user mysite-content-reader-service with path system/cq:services/mysite\nset principal ACL for mysite-content-reader-service\n    allow jcr:read on /content/mysite\n    allow jcr:read on /conf/mysite\nend",
    "create service user mysite-form-writer-service with path system/cq:services/mysite\nset principal ACL for mysite-form-writer-service\n    allow jcr:read,rep:write on /var/mysite/forms\nend",
    "create path (sling:Folder) /var/mysite/forms",
    "create group mysite-authors\nset ACL for mysite-authors\n    allow jcr:read,rep:write,crx:replicate on /content/mysite\n    allow jcr:read on /conf/mysite\nend"
  ]
}

Adobe's tutorials also use the .config format, which supports real multi-line strings. Either works; pick one per project. Repoinit runs at startup and on config change, and its statements are designed to be idempotent, so it's safe on every environment. Scripts execute with admin rights, so review them like code.

Tip: Draft ACLs with the Package Filter Builder and repoinit side by side. Content packages shouldn't carry rep:policy nodes for application-critical permissions on Cloud Service. Repoinit is the source of truth for those.

Principal-based authorization (Cloud Service)

The ACLs above are resource-based: entries sit on the content they protect. Oak 1.16 added an optional second model, principal-based authorization. Here entries are stored with the principal (a rep:PrincipalPolicy under the user), and each entry carries a rep:effectivePath saying where it applies. Only allow entries exist in this model. There is no deny.

Oak's built-in filter limits the model to system user principals located below a configured path. AEM as a Cloud Service uses /home/users/system/cq:services for that path. So on Cloud Service:

create service user mysite-content-reader-service with forced path system/cq:services/mysite
set principal ACL for mysite-content-reader-service
    allow jcr:read on /content/mysite
end

Adobe's service-user tutorial puts the rule in a comment: when using principal ACLs, the service user MUST be created under system/cq:services. If it isn't, the principal ACL can't be applied.

Why bother? A feature's permissions live in one place, next to its user, instead of scattered rep:policy nodes, so they're easy to audit and to delete when the feature is retired. The Oak docs also describe an aggregation filter: when principal-based evaluation fully covers a subject, evaluation can stop there and path-based entries are skipped. Pick one model per service user. Don't mix set ACL and set principal ACL for the same principal and expect them to add up.

A few more version details:

  • The Sling docs mark set principal ACL as deprecated in favour of ensure principal ACL, because the old statement silently does nothing if the principal ACL can't be applied. Adobe's Cloud Service docs still show set principal ACL. If your repoinit version supports ensure principal ACL, prefer it: it fails loudly, which is what you want in CI.
  • home(<user>) resolves a user's home path in a statement, for example allow jcr:read on home(mysite-ims-service).
  • AEM 6.5: classic 6.5 service packs don't ship the oak-authorization-principalbased bundle, so on 6.5 use resource-based set ACL for service users. If you're on 6.5 LTS, check the bundle list in /system/console/bundles before planning on principal ACLs.

Closed User Groups (CUG) on publish

A Closed User Group restricts read access to a subtree on publish to a list of principals. It's the standard way to build gated areas such as members-only pages, partner portals, and protected downloads. In current AEM versions it's built on Oak's CUG authorization module, and it splits into two independent parts:

PartStored asEffect
Authorizationrep:CugMixin on the node + a rep:cugPolicy child (rep:CugPolicy) with rep:principalNamesRead is denied to everyone except the listed principals (and configured excluded principals)
Authentication requirementgranite:AuthenticationRequired mixin, optional granite:loginPathAnonymous requests are redirected to the login page instead of receiving a 404

You set both from the page's Properties → Permissions → Edit Closed User Group. The relevant OSGi components are:

ComponentPIDKey properties
Oak CUG configurationorg.apache.jackrabbit.oak.spi.security.authorization.cug.impl.CugConfigurationcugSupportedPaths, cugEnabled
CUG exclusionsorg.apache.jackrabbit.oak.spi.security.authorization.cug.impl.CugExcludeImplprincipalNames
Authentication requirementcom.adobe.granite.auth.requirement.impl.DefaultRequirementHandlersupportedPaths

By default the supported path is /content. On author, CUGs can be managed but are not evaluated, so authors see and edit the content normally. On publish, CUGs are both managed and evaluated. Keep them few and at the top of protected subtrees. Adobe explicitly warns against redundant or nested CUGs where other authorization already restricts reads, because every CUG adds evaluation cost.

A CUG never grants anything the regular ACLs deny. It only removes read access for everyone not listed. The listed principals still need normal jcr:read.

CUGs and the Dispatcher cache

The Dispatcher serves cached files without asking AEM, so a cached gated page goes to whoever asks next. The first line of defence is /allowAuthorized "0" (the default). Requests that carry authentication information aren't cached. That means an Authorization header, an authorization cookie, or a login-token cookie. That's safe, but every member request is a cache miss.

To cache protected pages and keep them protected, use permission-sensitive caching. With the /auth_checker section configured, the Dispatcher sends a HEAD request to a servlet you write, passing the requested URI. It serves the cached file only if the servlet returns 200, and returns a 403 otherwise:

/cache {
  /allowAuthorized "1"

  /auth_checker {
    /url "/bin/permissioncheck"
    /filter {
      /0000 { /glob "*" /type "deny" }
      /0001 { /glob "/content/mysite/members/*.html" /type "allow" }
    }
    /headers {
      /0000 { /glob "*" /type "deny" }
      /0001 { /glob "Set-Cookie:*" /type "allow" }
    }
  }
}
@Component(service = Servlet.class, property = {
        "sling.servlet.paths=/bin/permissioncheck",
        "sling.servlet.methods=HEAD"
})
public class PermissionCheckServlet extends SlingSafeMethodsServlet {

    @Override
    protected void doHead(SlingHttpServletRequest request, SlingHttpServletResponse response) {
        String uri = request.getParameter("uri");
        Session session = request.getResourceResolver().adaptTo(Session.class);
        try {
            // uri is the requested URL, e.g. /content/mysite/members/page.html.
            // Resolve it to a resource first so the check runs against the real path.
            Resource resource = request.getResourceResolver().resolve(request, uri);
            session.checkPermission(resource.getPath(), Session.ACTION_READ);
            response.setStatus(HttpServletResponse.SC_OK);
        } catch (Exception e) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }
    }
}

The check runs with the requesting user's session, which is the whole point. Also:

  • Allow the servlet through Dispatcher /filter rules, and make it cheap. It runs on every protected cache hit.
  • Mind the CDN. A CDN in front of the Dispatcher (always the case on Cloud Service) knows nothing about your auth checker. Adobe's documentation recommends sending Cache-Control: private for protected content so the CDN and browsers don't cache it for everyone.
  • Test as anonymous. After any change, request a gated URL with no cookies, directly and through the CDN. You should get the login redirect, never the cached page. The Dispatcher Tester helps with the filter side.

Users on Cloud Service vs 6.5

AEM as a Cloud Service: IMS and the Admin Console

On Cloud Service, author logins go through Adobe IMS. Users and groups are managed in the Adobe Admin Console, not in AEM:

  • Each environment gets two product profiles, AEM Administrators and AEM Users. An IMS user must be in one of them to log in at all. Adobe warns not to rename these profiles; renaming AEM Administrators removes admin rights from everyone assigned to it.
  • Members of AEM Users log in with read access. Members of AEM Administrators get administrative rights.
  • A user's IMS user groups sync into AEM when they log in, and appear as AEM groups. You grant permissions by adding those synced groups as members of local AEM groups, such as dam-users or your own mysite-authors. Keep the ACLs on the local groups, which you define with repoinit when code depends on them.
  • For large organisations, the User Sync Tool syncs users from Active Directory or LDAP into the Admin Console.

The mental model: IMS decides who you are and which groups you're in; AEM's local groups and ACLs decide what those groups can do.

AEM 6.5: local users and LDAP

On 6.5, users and groups are local to the instance by default (Tools → Security), often fed by LDAP through Oak's external identity management or SAML. AMS customers can also integrate IMS. The permission model is identical; only the identity source differs.

SAML SSO on publish

For end-user login on publish (members' areas, partner portals), Cloud Service supports SAML 2.0. It's configured as a factory OSGi config, com.adobe.granite.auth.saml.SamlAuthenticationHandler~<name>.cfg.json, on the publish tier. Author uses IMS; Adobe's SAML tutorial states that SAML isn't supported on author.

{
  "path": ["/content/mysite"],
  "idpUrl": "$[env:SAML_IDP_URL]",
  "idpCertAlias": "$[env:SAML_IDP_CERT_ALIAS]",
  "idpIdentifier": "$[env:SAML_IDP_ID]",
  "serviceProviderEntityId": "$[env:SAML_AEM_ID]",
  "useEncryption": true,
  "spPrivateKeyAlias": "$[env:SAML_AEM_KEYSTORE_ALIAS]",
  "keyStorePassword": "$[secret:SAML_AEM_KEYSTORE_PASSWORD]",
  "createUser": true,
  "userIntermediatePath": "mysite/idp",
  "addGroupMemberships": true,
  "groupMembershipAttribute": "groupMembership",
  "defaultGroups": ["mysite-members"],
  "identitySyncType": "idp_dynamic_simplified_id"
}

The handler authenticates; your CUGs or ACLs decide what users can read, typically by listing the groups mapped from the IdP's group attribute. SAML also needs the IdP certificate in the global trust store, the authentication-service keystore when encrypting, a Dispatcher filter allowing POST to */saml_login, and a Referrer Filter entry for the IdP host. idp_dynamic_simplified_id enables dynamic group membership, which Adobe describes as better for performance.

Security hardening checklist

Permissions are one layer. The others, mostly from Adobe's AEM and Dispatcher security checklists:

The perimeter (Dispatcher)

  • Deny by default in /filter, then allow narrowly. Block /crx/*, /system/*, /bin/querybuilder*, /libs/* (apart from the few client-side endpoints you need), .infinity.json, .tidy.json, and selectors on JSON. The Dispatcher guide has a full probe list.
  • Keep /allowAuthorized "0" unless you've implemented permission-sensitive caching.

Credentials (6.5 in particular)

  • Change the admin password, and the OSGi Web Console credentials (a separate setting in the Apache Felix OSGi Management Console configuration).
  • Remove or re-password the demo author user. Use dedicated, least-privilege users for replication and transport, never admin.
  • Run production in production ready mode (-r nosamplecontent). It removes sample content and disables CRXDE Lite and WebDAV access on publish. Adobe notes it covers most but not all checklist items.
  • Cloud Service removes much of this: admin access comes through IMS, /apps and /libs are immutable, and deployment goes only through Cloud Manager.

Request forgery

  • CSRF protection framework. Authenticated POST, PUT, and DELETE requests on author and publish need a CSRF token. Include the granite.csrf.standalone client library (or granite.jquery), which fetches /libs/granite/csrf/token.json and sends the token as the CSRF-Token header (or the :cq_csrf_token parameter for forms). The filter, com.adobe.granite.csrf.impl.CSRFFilter, returns 403 without a valid token. On publish, allow GET /libs/granite/csrf/token.json through the Dispatcher.
  • Sling Referrer Filter (org.apache.sling.security.impl.ReferrerFilter). It checks the Referer header on modifying methods against allow.hosts, with allow.empty and filter.methods. Add your real hostnames and your IdP. Don't "fix" a 403 by allowing everything.

Output encoding (XSS)

  • HTL escapes output by context automatically: HTML text, attributes, URLs (href, src), and so on. Override the context only deliberately, for example @ context='html' for rich text, which is filtered against AEM's XSS rules, rather than @ context='unsafe'. The HTL reference lists every context.
  • In Java, use XSSAPI to filter or encode anything you write into markup outside HTL.

Everything else

  • Restrict anonymous and everyone on publish to exactly the content that should be public. Adobe provides an Anonymous Permission Hardening Package for 6.5.
  • Custom error handlers, so stack traces never reach visitors.
  • Set clickjacking protection (X-Frame-Options or CSP frame-ancestors) and other headers. Check them with the Security Headers tool.
  • On 6.5, keep json.maximumresults on the Apache Sling GET Servlet at a sane value (the checklist uses 1000) so the default JSON renderer can't dump huge trees.

Cheat sheet

NeedUseWhere / notes
Who can read/write a nodeACE in rep:policyrep:GrantACE / rep:DenyACE, by principal name
Full authoring writerep:writejcr:write + jcr:nodeTypeManagement
Publish permissioncrx:replicateChecked by AEM, not the JCR
Only this node, not childrenrep:glob = ""Absent glob means whole subtree
Only certain propertiesrep:itemNamese.g. jcr:title,jcr:description
Who winsuser > group; closer > inherited; later > earlierOak evaluation rules
See real accessPage Properties → Permissions → Effective PermissionsOr Tools → Security → Permissions
Backend accessService user + getServiceResourceResolverNever getAdministrativeResourceResolver
Map bundle to userServiceUserMapperImpl.amended-*.cfg.jsonbundle:subservice=[principal]
Users/ACLs as codeRepoinitRepositoryInitializer~name.cfg.json, scripts
Service user ACLs on Cloud Serviceset principal ACL / ensure principal ACLUser must be under system/cq:services
Gated publish contentCUG + authentication requirementrep:cugPolicy, granite:AuthenticationRequired
Cache gated pages safely/auth_checker + servletPlus Cache-Control: private for the CDN
Member SSO (publish)SAML handlercom.adobe.granite.auth.saml.SamlAuthenticationHandler~
CSRFgranite.csrf.standaloneCSRF-Token header, /libs/granite/csrf/token.json

Best practices

  • ✅ Grant to groups, never to individual users; nest IMS or LDAP groups into local groups that carry the ACLs.
  • ✅ Prefer allow; use deny rarely, deliberately, and high in the tree.
  • ✅ Define service users, application groups, and their ACLs in repoinit, reviewed and deployed like code.
  • ✅ One service user per task, named entity-task-service, with the minimum privileges and the [principal] mapping format.
  • ✅ On Cloud Service, create service users under system/cq:services and use principal ACLs for them.
  • ✅ Use restrictions (rep:glob, rep:itemNames) instead of splitting content just to fit permissions.
  • ✅ Keep gated content under dedicated paths so CUGs, auth checker filters, and CDN rules can target them cleanly.
  • ✅ Verify permissions with Effective Permissions and by logging in as a test user, not as admin.

Do's and Don'ts

Do

  • ✅ Use the requesting user's resolver in servlets whenever the user's own permissions should apply.
  • ✅ Send Cache-Control: private on protected responses so the CDN doesn't cache them.
  • ✅ Put each complete repoinit block in one scripts entry in .cfg.json.
  • ✅ Test gated URLs anonymously, directly and through the CDN, after every change.
  • ✅ Run the Adobe security checklist before every go-live, and on 6.5 use production ready mode.

Don't

  • ❌ Don't use getAdministrativeResourceResolver or loginAdministrative. Use service users.
  • ❌ Don't grant anything to everyone you wouldn't publish to the whole internet, and don't modify or delete the group.
  • ❌ Don't put service users in groups; principal mappings don't resolve group membership anyway.
  • ❌ Don't mix resource-based and principal-based ACLs for the same service user.
  • ❌ Don't hand-edit rep:policy nodes or ship application ACLs only in content packages on Cloud Service.
  • ❌ Don't nest CUGs or add them where ACLs already restrict reads. It costs performance for no gain.
  • ❌ Don't silence a CSRF or Referrer Filter 403 by allowing all hosts or excluding paths wholesale.

Wrapping up

AEM security is one model applied everywhere. Authentication turns a request into a subject with principals; Oak evaluates ACEs for those principals by strict rules (user over group, closer over inherited, later over earlier); privileges and restrictions make each entry precise. Add group-based ACLs, service users defined in repoinit, principal-based ACLs on Cloud Service, and CUGs protected through the Dispatcher and CDN, and your permissions become reviewable, reproducible code. The perimeter work (filters, CSRF, Referrer Filter, HTL escaping, credentials) keeps that model from being bypassed.

Continue with the Dispatcher guide for the filter and cache side of security, the Cloud Service guide for how repoinit and config fit the cloud deployment model, the 6.5 to Cloud Service migration guide for moving legacy users and ACLs, and the Sling guide for resource resolvers in depth.

Further reading

Share this article

Discussion

By commenting you agree to the Privacy Policy. Guest comments are reviewed before they appear.

Loading discussion…

Subscribe to the Newsletter

Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

Back to Blog