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
8.2 Validating an outbound URL against an allow-list (SSRF defence)
8.3 Encrypted OSGi configuration property
9. Common Mistakes
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
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
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.