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.
When agents use itUse 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`.
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill aem-security -a github-copilot
# or with the org installer (adds .github/skills/aem-security):
npx -y github:AGCO-Global/org-skills add skill aem-security
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the aem-architecture-skills plugin, which bundles all AEM / Architecture skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install aem-architecture-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill aem-security -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill aem-security -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill aem-security -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill aem-security -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Try it — example prompts
Prompts this skill is tested against, and what a good answer includes.
Write the repoinit for a service user that reads product data under /content/acme and writes import results to /var/acme/import.
create service user … with forced path system/cq:services/acmeset principal ACL for … endallow jcr:read on /content/acmewrite privilege only on /var/acme/importbind via ServiceUserMapped and close the resolver
We need a members-only section on our publish site. How do we protect it and keep it fast?
CUG on the subtree root with the allowed group and a login pageprotected pages are uncacheable by defaultpermission-sensitive caching with an auth checker as the caching optionalternative: public cacheable page plus client-side authenticated fragmenttest anonymously in a clean browser profile
Add a rate limit at the CDN — we're getting scraped.
traffic filter rules in cdn.yaml through the config pipelinestart in log-only mode and review what would have been blockedcheck your own integrations and monitoring before enforcingthen switch to block and keep watching
Our custom form gets a 403 from AEM. Someone suggested turning off the CSRF filter.
do not disable the filter — it removes protection site-widefetch a token from /libs/granite/csrf/token.json and send it with the requestconfirm the dispatcher permits the token endpoint
Where do we put the API key for the pricing service our OSGi component calls?
$[secret:…] in the OSGi configurationvalue set as a Cloud Manager secret environment variablenever a literal in .cfg.json or in Gita key already committed must be rotated, not just deleted
We want to add a Content-Security-Policy but our clientlibs use inline scripts.
start in report-only mode with a reporting endpointmove inline script and style into clientlib filesnonce for what must remain inline, not unsafe-inlinea looser policy for author, which needs framing and inline executionenforce on publish only after reports are clean
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
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.
---
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.