> For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt.

# Security Best Practices for AEM Developers

## 29 – Security Best Practices for AEM Developers

> **Target:** 3–4 years experienced AEM Developer **Covers from your additional list:** security best practices — pulling together and extending the security threads already touched in files 07, 08, 13, 19 and 28 into one coherent picture. **Project domain:** a global energy technology company's marketing site.

***

## Before we start — why this file is mostly cross-references, on purpose

If you've read through this repository in order, you've already met most of AEM's security surface without it being labelled "security": HTL's automatic contextual escaping (file 08), the CSRF token filter (file 07), the deny-by-default dispatcher filter (file 19), service users and least privilege (file 13), and the security gate in the Cloud Manager pipeline (file 28). That's not an accident, and it's actually the right way to think about security in an interview: **it isn't one topic bolted on at the end, it's a property that shows up in every layer**, and an interviewer who asks "how do you think about security in AEM" is really asking whether you can name the specific mechanism at each layer rather than give a generic answer about "following best practices."

This file's job is to pull those threads into one place, add the pieces that don't have a natural home elsewhere (the CSRF token lifecycle in depth, encrypted OSGi properties, SSRF, dependency vulnerabilities), and give you the single coherent narrative an interviewer is actually listening for.

***

## 1. Introduction

### 1.1 The AEM security surface, as one picture

flowchart TD CDN\["CDN"] --> Dispatcher\["Dispatcher\nfile 19: deny-by-default filter,\nsecurity headers, rate limiting"] Dispatcher --> Publish\["Publish\nanonymous — reads only, no admin console"] Dispatcher --> Author\["Author\n(never public) — authenticated,\nservice users with least privilege — file 13"] Author --> Code\["Application code\nHTL context-escaping — file 08\nCSRF token filter — file 07\nsafe QueryBuilder usage — file 21"] Code --> Pipeline\["Cloud Manager security gate\nfile 28: dependency + code scanning"] Config\["OSGi configuration\nencrypted secrets, never hardcoded"] --> Code

Every one of those boxes is a place where a specific, nameable mechanism does a specific job. That's the structure this file follows.

### 1.2 A real project example

**Requirement.** A "Request a Quote" form on the energy company's site needed to call an internal pricing API and email the result to the sales team.

**What made it a security-relevant story.** The first draft built the outbound API URL by concatenating a product code the visitor selected from a dropdown directly into the request URL, and rendered the confirmation message using the visitor's freely-typed name field without going through the model's escaping path.

**What actually went wrong, and how it surfaced.** Nothing went wrong in production — the Cloud Manager security gate flagged the string-built URL as a hotspot (file 28), and a code reviewer caught the raw name field being rendered with `context='unsafe'` copied from another component without the justification that made it safe there (an XSS hole, file 08's exact warning about the "mirror-image bug").

**The fix.** The product code was validated against a fixed list before use, and the confirmation message went back through the default HTML-escaping context.

**The lesson.** Neither issue was exotic — they were the two most common AEM security mistakes, caught by the two most ordinary mechanisms: a quality gate and a human reviewer who actually read the diff rather than trusting that "it looks like the pattern next to it."

***

## 2. Core Concepts

### 2.1 Cross-Site Scripting (XSS) — the recap, and what to add

File 08 already covers the mechanism in depth: HTL escapes by context automatically, and `context='unsafe'` is the one place a developer can reintroduce the hole. The one thing worth adding here: **XSS risk isn't limited to HTL.** A servlet writing a raw JSON or HTML response with `response.getWriter().write(...)` gets none of HTL's automatic protection — if that servlet ever echoes back a request parameter, it needs the same escaping discipline applied by hand (or via a library, never by manual string surgery).

### 2.2 Cross-Site Request Forgery (CSRF) — the mechanism in full

File 07 established that AEM ships a CSRF token filter and that a public POST endpoint needs it. Here's the mechanism itself:

- A page load includes a CSRF token, obtainable from `/libs/granite/csrf/token.json`, tied to the visitor's session.
- A state-changing request (POST, PUT, DELETE) is expected to include that token, typically as a header (`CSRF-Token`) or a hidden form field.
- AEM's **Referrer Filter** and **CSRF filter** validate the token before the request reaches application code — for excluded paths, they don't, so a public form endpoint that opts out of the filter (often because it's meant to be called from outside the page, e.g. an API) takes on the responsibility of validating the token itself, or must accept the CSRF risk consciously rather than by accident.

**Why this matters:** a forged cross-site POST works precisely because a browser automatically attaches a visitor's session cookie to any request to a site they're logged into, regardless of which site initiated it. The token defeats this because an attacker's page has no way to read it — same-origin policy blocks that — so a forged request can't include a valid token even though it can include valid cookies.

### 2.3 Injection — recap and the AEM-specific case

Standard injection concerns (SQL injection) mostly don't apply directly to a JCR-backed repository, but the **equivalent risk exists in JCR-SQL2/XPath and QueryBuilder predicate construction** (file 21) — building a query by concatenating unvalidated input lets an attacker manipulate the query's meaning, not just its intended parameter. File 28 already showed this as a Sonar-flagged security hotspot; the fix is the same principle SQL injection defenses use: never build a query from untrusted string concatenation, validate or parametrise instead.

### 2.4 Access control and least privilege — recap

File 13's whole subject is this: service users scoped to exactly what they need, ACLs following deny-wins-at-same-level and closest-ACL-wins, and the specific anti-pattern file 28 flagged independently — `getAdministrativeResourceResolver()` as a shortcut that bypasses this entire model. The security angle worth adding: **a service user with `jcr:all` "to be safe" is a bigger blast radius than the code that uses it ever needed**, and every credential or session an application holds is a thing that can eventually be misused, intentionally or by a bug — so granting only what's used isn't caution for its own sake, it's bounding the damage a future mistake can do.

### 2.5 Server-Side Request Forgery (SSRF)

**The risk that doesn't have a natural home in an earlier file.** Any AEM service or servlet that makes an outbound HTTP call based on user-influenced input (a URL, a hostname, even a path fragment used to build one) can potentially be tricked into requesting an internal resource the attacker couldn't otherwise reach directly — an internal admin endpoint, a cloud metadata service, or another service on the private network.

**The defence:** validate that any user-influenced part of an outbound request matches an allow-list of expected values or hosts, rather than trusting it to only ever contain what the UI intended to send. This is the same discipline as the injection defence — untrusted input shapes _what_ gets requested only within bounds you've explicitly permitted, never arbitrarily.

### 2.6 Sensitive configuration — never hardcoded, and never plaintext where avoidable

File 28 already flagged a hardcoded, credential-shaped string as both a code smell and a security hotspot. The fuller picture: OSGi configuration properties that hold genuinely sensitive values (an API key, a shared secret) should use AEM's built-in property encryption — a value saved through the OSGi configuration UI in the form `{ENC}...` is encrypted at rest using the instance's key, rather than sitting in plaintext in a `.cfg.json` file that ends up in a content package (and therefore in Git, per file 25). Committing a plaintext secret into a Git-tracked configuration file is one of the more common real-world AEM security incidents, precisely because it's easy to do without noticing — the file looks like ordinary configuration.

### 2.7 Security headers — recap from the dispatcher layer

File 19 already covers the mechanics of setting headers like `X-Frame-Options`, `Referrer-Policy`, and a `Content-Security-Policy` at the dispatcher/web-server layer. The concept worth stating plainly here: **these headers are a browser-enforced second line of defence**, not a replacement for fixing an actual XSS hole — `X-Frame-Options: SAMEORIGIN` prevents a page from being framed by another site (defeating clickjacking) regardless of what the page's own code does, and a `Content-Security-Policy` can block inline scripts from executing even if one somehow got injected. Defense in depth: the application layer should still be correct, and the headers reduce the damage on the day it isn't.

### 2.8 Dependency vulnerabilities — recap from file 28

The Cloud Manager security gate scans for known vulnerabilities in dependencies, which is the platform-level answer to the practical problem that a third-party library used inside a bundle can carry a vulnerability nobody on the project wrote. The developer-level habit that supports this: don't add a dependency casually (file 25's Maven scope discipline extends here too) — an unused or outdated library is attack surface that provides no value.

### 2.9 Authentication and session handling on Cloud Service

Author instances are never public (file 14) — the entire authentication surface for a marketing site's editorial team is internal. Publish is anonymous by default for a marketing site, which is precisely why file 13's "works on author, blank on publish" pattern exists: publish has no session to be attacked in the way author does, because there's no login for a typical visitor at all. Where authentication does matter on the publish side — a gated content area, a headless API consumed by an authenticated mobile app — token-based authentication (OAuth-style bearer tokens) is the standard pattern, kept entirely separate from the author instance's own authentication.

***

## 3. Internal Working

### 3.1 How the CSRF token is actually validated

The token issued from `/libs/granite/csrf/token.json` is tied to the requester's session server-side. When a subsequent state-changing request arrives, AEM's CSRF filter checks the submitted token against what it expects for that session before the request is dispatched to the target servlet at all — the servlet's own code never runs if the token check fails, so a correctly configured endpoint gets this protection without writing any token-checking code itself.

### 3.2 Why HTL's context escaping can't be bypassed accidentally, but can be bypassed deliberately

HTL infers escaping context (HTML, URI, JavaScript) from where an expression sits in the markup, by default. `context='unsafe'` is the single explicit override that disables this — which is exactly why file 08 treats it as a flag to justify in a code comment every time it's used: it's not that the mechanism is weak, it's that it has one deliberate door out, and every use of that door needs to independently prove the content was already safe (typically because a serialiser, not hand-built string concatenation, produced it).

### 3.3 How the dispatcher filter's deny-by-default posture actually stops an attack class

File 19 already covers `/filter` 's deny-by-default mechanics for legitimate traffic shaping. The security framing: an attacker probing for exposed admin endpoints (`/crx/de`, `/system/console`) on a public-facing publish tier is stopped at the dispatcher, before the request ever reaches AEM's own authentication check — one fewer thing that has to be perfectly configured on the AEM side for that endpoint to be inaccessible.

***

## 8. Coding Examples

### 8.1 CSRF token attached to a fetch request

```javascript
// Fetch the token, then attach it as a header on the state-changing request.
// A forged request from another origin cannot read this token — same-origin
// policy blocks it — even though the browser would still attach cookies.
fetch('/libs/granite/csrf/token.json')
  .then((res) => res.json())
  .then(({ token }) => fetch('/bin/energy/quote-request', {
    method: 'POST',
    headers: { 'CSRF-Token': token, 'Content-Type': 'application/json' },
    body: JSON.stringify(formData)
  }));
```

### 8.2 Validating an outbound URL against an allow-list (SSRF defence)

```java
// Rejects anything that isn't a known-safe destination, rather than
// trusting a caller-supplied URL to only ever point where intended.
private boolean isAllowedHost(String url) {
    try {
        URI uri = new URI(url);
        return ALLOWED_HOSTS.contains(uri.getHost());
    } catch (URISyntaxException e) {
        return false; // malformed input is rejected, not tolerated
    }
}
```

### 8.3 Encrypted OSGi configuration property

```json
{
  "pricing.api.key": "{ENC}AbCdEf1234567890EncryptedValueHere=="
}
```

```text
The plaintext value is entered once through the OSGi configuration UI —
AEM encrypts it using the instance's key and stores the {ENC}... form.
The plaintext never appears in the exported .cfg.json that ends up in Git.
```

***

## 9. Common Mistakes

| Mistake                                                             | Why it happens                                  | The actual cost                                                             |
| ------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
| Committing a plaintext secret to a config file                      | Looks like any other configuration value        | Permanently in Git history, even after rotation                             |
| Copying `context='unsafe'` from another component                   | The pattern "worked" elsewhere                  | A genuine XSS hole on a field with no restriction                           |
| Excluding a form endpoint from the CSRF filter for convenience      | Token handling feels like friction              | Removes real protection against forged requests                             |
| Building a query from concatenated user input                       | Feels like the fastest way to filter results    | Query injection, and a flagged Sonar hotspot                                |
| Using `getAdministrativeResourceResolver()` under deadline pressure | Faster than configuring a service user          | Bypasses least privilege, flagged by tooling, inconsistent on Cloud Service |
| Trusting a caller-supplied URL for an outbound request              | No obvious "attacker" in an internal tool       | SSRF against internal infrastructure                                        |
| Treating a passed security scan as proof of safety                  | Conflates automated coverage with actual review | Business-logic and access-control gaps a scanner can't see ship anyway      |
| Granting a service user broad privilege "to be safe"                | Avoids revisiting permissions later             | A larger blast radius than the code ever needed                             |

***

## 10. Best Practices

- **Never override HTL's default escaping without a specific, stated reason** — and only when a serialiser, not manual concatenation, produced the string.
- **Never exclude a state-changing endpoint from CSRF protection for convenience** — only for a genuinely different authentication mechanism, documented as such.
- **Validate and bound any input that reaches a query or an outbound request** — never build either by raw string concatenation from user input.
- **Use a scoped service user, never an admin-session shortcut**, and grant only what the code actually uses.
- **Encrypt sensitive OSGi configuration values**, and never assume "I'll rotate it later" undoes a plaintext commit.
- **Treat security headers as a backstop, not a fix** — they reduce blast radius; they don't replace correct application code.
- **Don't add a dependency casually** — every library is attack surface, whether or not it's ever exploited.
- **Ask "what if this input points somewhere I didn't intend" for any code that builds a query, a URL, or a file path from user-influenced data.**

***

## 11. Debugging Tips

| Symptom                                                            | Where to look                                             | What it usually means                                            |
| ------------------------------------------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------------- |
| A form submission fails with a 403/token error                     | Whether the CSRF token was fetched and attached correctly | Token missing, expired, or the endpoint excluded incorrectly     |
| Unexpected script execution or malformed rendering of user content | Every `context='unsafe'` usage touching that content path | An escaping override applied somewhere it shouldn't be           |
| A Sonar security hotspot on a query or URL-building line           | Where the value being concatenated actually comes from    | Untrusted input reaching a query or outbound request unvalidated |
| A configuration file has a suspicious-looking plaintext value      | Whether it should be an `{ENC}...` encrypted property     | A secret committed in plaintext                                  |
| A service is being used to reach an unexpected internal address    | Whether it validates the target of any outbound request   | An SSRF gap                                                      |

***

## 12. Performance Notes

Security checks add negligible runtime cost relative to the cost of an incident: CSRF token validation and HTL's context-aware escaping are effectively free per-request operations; input validation on a form endpoint is a handful of checks, not a bottleneck. Where security work does have a cost is developer time — validating an allow-list, encrypting a config value, writing the code review that catches a copied `unsafe` usage — and that cost is what a Cloud Manager security gate and disciplined code review are there to make routine rather than heroic.

***

## 15. Comparison Tables

|                     | **XSS**                                         | **CSRF**                                                      | **SSRF**                                           |
| ------------------- | ----------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- |
| What's exploited    | Unescaped output rendered by a victim's browser | A browser automatically attaching cookies to a forged request | A server making a request an attacker steers       |
| Primary AEM defence | HTL context-aware escaping                      | CSRF token filter                                             | Input validation / allow-listing on outbound calls |
| Where it "lives"    | Application/template layer                      | Request-handling layer                                        | Service/backend layer                              |

|                       | **Security header (dispatcher)** | **Application-layer fix**               |
| --------------------- | -------------------------------- | --------------------------------------- |
| Enforced by           | The visitor's browser            | The application's own code              |
| Fixes the root cause? | No — reduces blast radius        | Yes                                     |
| Example               | `X-Frame-Options`, CSP           | Correct HTL escaping, validated queries |

|                             | **Admin-session shortcut** | **Scoped service user** |
| --------------------------- | -------------------------- | ----------------------- |
| Privilege granted           | Everything                 | Exactly what's used     |
| Flagged by tooling          | Yes (file 28)              | No                      |
| Consistent on Cloud Service | Not reliably               | Yes                     |

***

## Next topic

**Java fundamentals for AEM developers** — the core Java language features (collections, generics, exceptions, concurrency basics, streams) that show up throughout AEM development and testing, built up from first principles for a developer whose Java is still a work in progress.

***

_Topic 29 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain._
