---
name: aem-security
description: >
  Designs and hardens security for AEM as a Cloud Service across the whole stack — author access through Adobe IMS and Admin Console product profiles, principal-based ACLs and service users in repoinit, closed user groups and permission-sensitive caching, CDN traffic filter and WAF rules, purge keys, secrets and OSGi configuration values, CSRF, and security headers including CSP for client libraries. Use this whenever the user grants or restricts access to content, writes a repoinit service user or ACL, protects a section of a site behind login, configures cdn.yaml traffic filter or WAF rules, handles an API key or credential in an OSGi config, sets CSP, HSTS or framing headers, or asks how a page stays cacheable while being permission-checked. For dispatcher filter and cache rule syntax use `aem-dispatcher`.
metadata:
  technology: AEM
  type: security
---

# AEM Security
> **Targets:** AEM as a Cloud Service 2026.x (IMS, principal-based ACLs, CDN traffic filters) · **Verified:** 2026-09 against https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/security/home

AEM security is layered, and each layer fails differently: the CDN decides who reaches you at all, the dispatcher decides what is even a valid URL, the repository decides who may read a node, and the code decides what it does with the session it was given. Most real incidents are not clever exploits — they are a permissive dispatcher filter, a service user with more rights than it needs, or a page that was cached without noticing it was personalised. Design every layer explicitly and assume the ones in front of it will one day be bypassed.

## 1. Decide first

| Question | Default | Change when |
|---|---|---|
| Author access | Adobe IMS with Admin Console product profiles mapped to AEM groups | Never local AEM users on Cloud Service |
| Permissions model | Group-based, granted on as few nodes as possible | Per-user ACLs are unmaintainable — always a group |
| Service user rights | `set principal ACL`, read-only, scoped to one path | Write access only where the job genuinely writes, on that subtree only |
| Protecting content on publish | Closed User Group (CUG) with permission-sensitive caching | Public-plus-personalised fragments: cache the page, fetch the fragment client-side |
| Blocking abuse | CDN traffic filter rules (rate, geo, IP), WAF rules where licensed | Dispatcher rules are the last resort, not the first |
| Secrets | `$[secret:…]` in OSGi config, set as a Cloud Manager environment variable | Never a literal in `.cfg.json`, never in Git |
| Purge authorisation | Purge key kept as a secret, rotated | A shared, never-rotated key is a standing outage risk |
| Security headers | Set at the CDN/dispatcher for every response | Per-page headers only for genuine exceptions |

## 2. Identity and author access

- Authors and developers authenticate through **Adobe IMS**. Product profiles in the Admin Console map to AEM groups; membership is managed there, not in AEM.
- Map profiles to a small number of AEM groups with real names (`content-authors-emea`, `dam-editors`), and grant permissions to those groups only.
- Review the mapping when people change teams — leavers are handled by the Admin Console, but a moved author keeps whatever their old group had.

## 3. Repository permissions and service users

Service users are declared in repoinit. On Cloud Service the correct form places them under `system/cq:services` and grants **principal-based** ACLs:

```
create service user acme-price-sync with forced path system/cq:services/acme

set principal ACL for acme-price-sync
    allow jcr:read on /content/acme
end
```

- `with forced path system/cq:services/<project>` is the Cloud Service form; `set principal ACL` is principal-based authorization, the default under that path.
- One service user per job, named for the job, with the narrowest path and the fewest privileges. A service user with `jcr:all` on `/` is the AEM equivalent of running as root.
- Grant read; grant write only on the specific subtree the job writes to.
- `loginAdministrative` does not exist on Cloud Service. Code that wants it is code that has not decided what it actually needs.

Closed User Groups, permission-sensitive caching and the full repoinit patterns: `references/permissions-and-service-users.md`.

## 4. Protecting content on publish

- A **CUG** restricts a content subtree to a group and redirects everyone else to a login page.
- Protected pages are, by default, uncacheable — which is why **permission-sensitive caching** exists: the dispatcher caches the page and asks AEM to authorise each request through an auth checker before serving it. It trades a small request to AEM for a cache hit on the body.
- The safer pattern for pages that are mostly public: cache the page for everyone and load the personalised fragment client-side, authenticated. A page that varies per user and is cached without a permission check is the classic data-leak incident.

## 5. The edge: traffic filters, WAF and purge

- **Traffic filter rules** in `cdn.yaml` handle rate limiting, geography and IP allow/deny lists at the CDN, before the request costs you anything. WAF rules (separately licensed) add signature-based protection.
- Start in log-only mode, read what would have been blocked, then enforce. A rate limit tuned by guesswork blocks your own integrations first.
- **Purge keys** authorise cache invalidation. Treat them as secrets, rotate them, and never embed one in a client-side script.
- Set security headers once at the edge: HSTS, `X-Content-Type-Options`, a framing policy, a referrer policy, and a CSP. Client libraries make CSP non-trivial — inline scripts and styles need either a nonce or extraction. Header details and a CSP rollout path: `references/edge-and-headers.md`.

## 6. Secrets and configuration

- Secrets reach OSGi configuration as `$[secret:MY_API_KEY]`, with the value set as a Cloud Manager secret environment variable. Non-secret per-environment values use `$[env:MY_VALUE]`.
- Nothing secret belongs in Git, in a content package, in a clientlib or in a log line. Scan for this in review — a key committed once must be rotated, not deleted.
- CSRF protection is on by default for write requests; a custom form posting to AEM needs a token from `/libs/granite/csrf/token.json`. Disabling the filter to make a form work is a vulnerability, not a fix.

## Deliverable format

```
## Threat summary — what is being protected, from whom, and what the exposure is
## Identity — IMS product profiles → AEM groups → permissions
## Repository — repoinit for service users and ACLs, with the path and privilege per entry
## Content protection — CUGs, caching strategy per protected area
## Edge — traffic filter and WAF rules, rate limits, purge key handling
## Headers — the header set, and the CSP with its rollout plan
## Secrets — each secret, where it is set, and its rotation owner
## Verification — how each control is tested, including what a bypass attempt should see
## Open questions
```

## Checklist

- [ ] No local AEM users on Cloud Service; access comes through IMS product profiles.
- [ ] Permissions are granted to groups, never to individual users.
- [ ] Every service user is under `system/cq:services`, uses `set principal ACL`, and is scoped to one path with least privilege.
- [ ] No `loginAdministrative`, and no service user with `jcr:all` on `/`.
- [ ] Protected content has a CUG, and its caching strategy is explicit rather than assumed.
- [ ] No personalised response is cached without a permission check.
- [ ] Traffic filter rules were run in log-only mode before enforcing.
- [ ] Purge keys are secrets, rotated, and absent from client-side code.
- [ ] Security headers including CSP are set at the edge and verified on a real response.
- [ ] Every secret is `$[secret:…]`, and no credential is in Git or a content package.
- [ ] CSRF protection is enabled, with tokens obtained properly rather than the filter disabled.

## Anti-patterns

| Anti-pattern | Why it hurts | Fix |
|---|---|---|
| Service user with `jcr:all` on `/` | Any bug in that job becomes total repository compromise | One user per job, `set principal ACL`, narrowest path, read unless write is required |
| `set ACL` with `with path system/<proj>` on Cloud Service | Not the Cloud Service form; diverges from principal-based authorization | `with forced path system/cq:services/<proj>` + `set principal ACL` |
| Disabling the CSRF filter to make a form submit | Removes protection site-wide for one form | Fetch a token from `/libs/granite/csrf/token.json` |
| Caching a page that renders user-specific content | Serves one user's data to the next visitor | Permission-sensitive caching, or public page + client-side authenticated fragment |
| Permissions granted per user | Unauditable, and wrong the moment anyone moves team | Groups mapped from IMS product profiles |
| Secrets as literals in `.cfg.json` | Committed to Git forever and visible to anyone with repo access | `$[secret:…]` with a Cloud Manager secret variable |
| Enforcing new WAF or rate-limit rules immediately | The first thing blocked is usually your own integration | Log-only first, read the would-be blocks, then enforce |
| Relying on dispatcher filters as the only defence | One permissive rule exposes everything behind it | Deny by default at dispatcher *and* correct ACLs in the repository |

## Go deeper

- `references/permissions-and-service-users.md` — repoinit patterns, principal-based ACLs, CUG setup and permission-sensitive caching with its auth checker.
- `references/edge-and-headers.md` — traffic filter and WAF rule shapes, rate limiting, purge key handling, the security header set, and a CSP rollout that survives client libraries.
- Sibling skills: `aem-dispatcher` for filter and cache rule syntax; `aem-code-review` for code-level findings; `aem-cloud-manager` for where environment variables and secrets are set.
