blog

How to Secure an EDS Implementation: A Practical Checklist for Developers

Written by Swati | Oct 29, 2026, 6:26:48 AM

Security on an Edge Delivery Services (EDS) project is cheapest in the first week, before content, integrations and habits pile up. This checklist follows a project in the order you need it, from creating the repo to the weeks after launch.

EDS is not classic AEM. There is no Dispatcher, no publish tier running your Java code and no server session. You get a CDN serving static HTML, a public JSON API for anything authored as a sheet, a GitHub repo that is your deployment pipeline, and an Admin API that controls preview and publish. Each phase secures one of those surfaces when it comes into play.

The steps apply whichever way authors write content: Document Authoring, SharePoint, Google Drive, or AEM authoring with the Universal Editor. Where a step differs, it says so. Commands use placeholders: replace {repo} and {owner} with your GitHub repo and org, and {site} with your site name.

Phase 0: Decide where each check runs

Every check runs in one of four layers, and only two of them are yours at request time. Decide which layer owns a check before writing any code for it.

Layer

What it can enforce

What it cannot do

Browser (blocks, scripts.js)

Safe DOM handling, CSP compliance, UX checks

Anything a user must not bypass: client code is public and editable in DevTools

EDS delivery (*.aem.page, *.aem.live)

Site authentication, HSTS, path filtering, custom headers, CSP nonce

Per-user authorisation, sessions, secrets, business logic

Your CDN or edge worker (Fastly, Cloudflare, Akamai, CloudFront)

Login, sessions, token checks, cookies, geo/IP rules, extra headers, WAF

Nothing, as long as you build it: this is your server-side layer

Your APIs (App Builder, AEM, backend)

Secrets, captcha checks, rate limits, data validation

How the page itself is served

 

Two rules follow from this:

  • No secrets, tokens or permissions in the browser. An API key in scripts.js, a sheet or page metadata is public. If a feature needs a secret, name the server or edge component that holds it in the design.
  • Cookie flags on static pages do almost nothing. HttpOnly, Secure and SameSite matter only on cookies your edge or API sets with Set-Cookie. EDS pages set no cookies, and a cookie written by document.cookie can never be HttpOnly.
  • ☐ Protect main: pull request, one approval, lint must pass, no force pushes
  • ☐ Add CODEOWNERS for the files that control security
  • ☐ Turn on secret scanning and push protection, and scan in CI
  • ☐ Extend .hlxignore so only site files are served
  • ☐ Turn on Dependabot for npm and GitHub Actions
  • ☐ Install AEM Code Sync on this repo only; require two-factor authentication for the org
  • ☐ Make configuration changes without handling raw tokens
  • ☐ Turn on site authentication for preview and live during development
  • ☐ Give publish rights to named people only
  • ☐ Use scoped, expiring Admin API keys for automation, stored in a secret manager
  • ☐ Record who owns each site token and API key, and when it expires
  • Give it the smallest role the job needs, for example publish for a CI job that previews on merge.
  • Its value is shown once: put it straight into your CI secrets or a password manager.
  • Keys expire. Rotate them before they do.
  • Load it into a shell from the secret store, so it stays out of history and files:
  • ☐ Agree with authors: sheets and page metadata hold public data only
  • ☐ Keep private data (leads, embargoed prices, internal contacts, API keys) in a backend with its own access checks
  • ☐ Index only the properties the site displays, and exclude private paths
  • ☐ Never hide content with CSS or JavaScript: if it is in the page, it is public
  • ☐ Put "unpublish before you delete" in the authoring guide

Phase 1: Day one, set up the repo safely

There is no build server between your repo and production. Once the AEM Code Sync app is installed, a push reaches the site within seconds, so whoever can push to main can change the live site. Do this before the first feature branch.

Protect main. In Settings → Rules → Rulesets, or:

gh api -X PUT repos/{owner}/{repo}/branches/main/protection --input - <<'EOF'
{
"required_status_checks": { "strict": true, "contexts": ["build"] },
"enforce_admins": true,
"required_pull_request_reviews": { "required_approving_review_count": 1, "require_code_owner_reviews": true },
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
EOF

build is the lint job in the boilerplate's .github/workflows/main.yaml. With enforce_admins and a required approval, nobody merges their own pull request, so you need at least two people. On a private repo, branch protection needs a paid GitHub plan.

Add CODEOWNERS for the files that set the CSP, the scripts on every page, what gets served and where content comes from:

# .github/CODEOWNERS
/head.html @{owner}/tech-leads
/404.html @{owner}/tech-leads
/scripts/ @{owner}/tech-leads
/.hlxignore @{owner}/tech-leads
/fstab.yaml @{owner}/tech-leads
/helix-query.yaml @{owner}/tech-leads
/.github/ @{owner}/tech-leads

Scan for secrets. Turn on secret scanning and push protection in Settings → Code security. Then scan every pull request, including token formats GitHub does not recognise, and add the job to contexts above:

# .github/workflows/secret-scan.yaml
name: Secret scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # scan the full history
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: $

Repos owned by an organisation also need a GITLEAKS_LICENSE secret (a free key from gitleaks.io).

Extend .hlxignore. Every repo file is served at https://main--{repo}--{owner}.aem.live/<file> unless ignored. Keep your boilerplate's entries and add the folders new projects soon grow:

drafts/*
tools/scripts/*
_*

Do not ignore all of tools/: Sidekick plugins under tools/sidekick/ must stay served.

Turn on Dependabot. EDS serves no node_modules, but CI runs your dev dependencies with repo tokens. Add .github/dependabot.yml with weekly updates for the npm and github-actions ecosystems.

Phase 2: Before authors start, lock down preview, live and admin access

A new site's preview (*.aem.page) and live (*.aem.live) hosts are open to anyone, at an easy-to-guess URL: {branch}--{repo}--{owner}. Unreleased pages, campaign dates and draft prices sit there for months before launch, so close them before the first page is previewed.

Make changes without handling raw tokens. All access settings live in the Admin API's configuration service. For one-off changes, use the UIs on tools.aem.live (the Site Admin tool among them). You sign in through the Sidekick and your session stays in the browser, so no token is copied into a terminal, ticket or chat.

For anything scripted, use an Admin API key, not your personal sign-in token:

export AEM_API_KEY="$(op read 'op://Engineering/aem-config-key/credential')" # 1Password CLI; use your own store
ADMIN=https://admin.hlx.page/config/{owner}/sites/{site}
AUTH="authorization: token $AEM_API_KEY"

Creating keys and editing access needs the admin role, so keep an admin key only as long as the task takes and delete it afterwards.

1. Create a site token for systems such as your CDN or test tools, and keep the returned id and value:

curl -X POST "$ADMIN/secrets.json" -H "$AUTH"

2. Require sign-in or the token on preview and live (site authentication):

curl -X POST "$ADMIN/access/site.json" -H "$AUTH" -H 'content-type: application/json' \
--data '{"allow": ["*@yourcompany.com"], "secretId": ["<id from step 1>"]}'

access/site.json protects both hosts, for the whole site; there are no per-page rules. At go-live, remove it so live opens up, and keep preview protected with access/preview.json. Posting preview.json alone is not enough. Protecting live also stops the automatic PageSpeed checks on pull requests.

On AEM authoring, also enter the site token in AEM: Tools → Cloud Services → Edge Delivery Services Configuration → Properties → Authentication tab.

3. Restrict publishing with admin roles:

curl -X POST "$ADMIN/access/admin.json" -H "$AUTH" -H 'content-type: application/json' \
--data '{"role": {"admin": ["you@yourcompany.com"], "author": ["*@yourcompany.com"], "publish": ["lead.dev@yourcompany.com"]}}'

Each of these POSTs replaces the whole object, so GET it first and keep what is there. Anyone the roles do not list gets a 403, so keep yourself in admin. On AEM authoring, also add the technical account from the Authentication tab as "config_admin": ["<id>@techacct.adobe.com"], or publishing from the Universal Editor fails. The same roles decide what the Sidekick's Preview and Publish buttons can do; until you map any, every user can publish.

4. Check it from a signed-out shell. Every URL should return 401:

for host in main develop; do curl -sI "https://$host--{repo}--{owner}.aem.page/" | head -1; done
curl -sI "https://main--{repo}--{owner}.aem.live/query-index.json" | head -1

Phase 3: While modelling content, decide what may be published

On EDS, publishing is exposure: anything previewed or published is served as a URL, often in several formats, and site authentication only hides it until launch.

What you create

What becomes public

A spreadsheet (placeholders, redirects, form options)

Every column at /<path>.json, and the file's other sheets through ?sheet=<name>

An index in helix-query.yaml

Every indexed page, linked or not, with every selected property, at /query-index.json and similar

A page

The page, plus /<page>.plain.html and /<page>.md with its raw content

A file in the repo

The file at its path, unless .hlxignore excludes it

A deleted or moved page

The published copy, until it is unpublished

 

A safe index selects only what the listing renders and excludes drafts:

# helix-query.yaml
version: 1
indices:
blogs:
include: ['/blogs/**']
exclude: ['/blogs/drafts/**', '/**.json']
target: /blog-index.json
properties:
title:
select: head > meta[property="og:title"]
value: attribute(el, "content")
image:
select: head > meta[property="og:image"]
value: match(attribute(el, "content"), "https://[^/]+(.*)")

Sites on the configuration service can hold this in content/query.yaml instead; check which one your site reads.

Check it as an anonymous visitor, against preview with the site token now and against live on launch day:

SITE=https://main--{repo}--{owner}.aem.page
AUTH="authorization: token $SITE_TOKEN" # omit once the site is public

# Which sheets and indexes exist? 200 = public after launch
for p in query-index.json blog-index.json placeholders.json metadata.json redirects.json; do
printf '%-20s %s\n' "$p" "$(curl -s -H "$AUTH" -o /dev/null -w '%{http_code}' "$SITE/$p")"
done

# Which columns does an index expose, and does any JSON hold secrets or emails?
curl -s -H "$AUTH" "$SITE/query-index.json?limit=1" | jq '.columns'
curl -s -H "$AUTH" "$SITE/placeholders.json" | grep -iE 'key|token|secret|@[a-z0-9-]+\.[a-z]+'

Fix anything unexpected at the source: remove the column, narrow the index, or unpublish the page.

Phase 4: From the first block, write code that cannot be turned against users

Blocks decorate content from authors, sheets, query parameters and fetch() calls, and the boilerplate style leans on innerHTML. Set the rules before the first block is merged; changing habits across fifty blocks later is far harder.

  • ☐ Use textContent for text and createElement for structure
  • ☐ Use innerHTML only to move markup EDS already rendered, or for fixed strings in your own code
  • ☐ Sanitise any HTML built from data with DOMPurify (the AEM authoring boilerplate ships scripts/dompurify.min.js)
  • ☐ Send every URL from content or the query string through one shared safeUrl() helper
  • ☐ Check event.origin against an exact allow-list in every postMessage handler
  • ☐ Never use eval, new Function or setTimeout with a string

Teach the pattern with one example:

// Unsafe: a sheet value like <img src=x onerror=alert(1)> runs
card.innerHTML = `<h3>${row.title}</h3><p>${row.description}</p>`;

// Safe: values are always text
const h3 = document.createElement('h3');
h3.textContent = row.title;
const p = document.createElement('p');
p.textContent = row.description;
card.replaceChildren(h3, p);

Make HTML sinks visible in review. This .eslintrc.js rule warns rather than fails, so a legitimate use gets an eslint-disable comment explaining why it is safe. Airbnb already sets no-restricted-syntax, so copy its entries into the list, or they are lost:

'no-restricted-syntax': ['warn',
{ selector: "AssignmentExpression[left.property.name=/^(innerHTML|outerHTML)$/]",
message: 'Build DOM with createElement/textContent, or sanitise with DOMPurify.' },
{ selector: "CallExpression[callee.property.name='insertAdjacentHTML']",
message: 'Build DOM with createElement/textContent, or sanitise with DOMPurify.' },
],

Validate URLs in one place. url.startsWith('http') or a regular expression for javascript: lets http:// through and misses java\tscript:, //evil.com and https://www.example.com.evil.com. Parse with the URL API and allow only what you need:

// scripts/utils/safe-url.js
// Hosts that authored content may embed outside our own origin
const ALLOWED_HOSTS = new Set(['www.youtube-nocookie.com']);

/** A safe absolute URL, or null when the link must not be used. */
export default function safeUrl(raw, { allowExternal = false } = {}) {
if (typeof raw !== 'string') return null;
const value = raw.trim();
// browsers drop tabs and line breaks and read \ as /, which is how filters get bypassed
// eslint-disable-next-line no-control-regex
if (!value || /[\u0000-\u001F\u007F\\]/.test(value)) return null;
let url;
try { url = new URL(value, window.location.origin); } catch { return null; }
const localDev = url.hostname === 'localhost' && url.origin === window.location.origin;
if (url.protocol !== 'https:' && !(localDev && url.protocol === 'http:')) return null;
if (url.username || url.password) return null; // https://www.example.com@evil.com
const sameOrigin = url.origin === window.location.origin;
if (!sameOrigin && !(allowExternal && ALLOWED_HOSTS.has(url.hostname))) return null;
return url.href;
}

Use it at every sink, such as a.href = safeUrl(raw) ?? '#', and skip rendering an <iframe> when it returns null. These defaults suit iframes, redirects and query-string URLs. For ordinary author links, add an option that allows any https: host plus mailto: and tel:. If a redirect or server request depends on the URL, repeat the check at the edge or API.

Keep these cases as unit tests, with window.location.origin set to https://www.example.com:

Input

Expected

/cars/ev

https://www.example.com/cars/ev

http://www.example.com/a

rejected (not HTTPS)

javascript:alert(1), JaVaScRiPt:alert(1), java\tscript:alert(1), data:text/html,…

rejected

//evil.com/x, /\evil.com

rejected

https://www.example.com.evil.com/, https://www.example.com@evil.com/

rejected

https://www.youtube-nocookie.com/embed/x

allowed with allowExternal, rejected without

 

Phase 5: If you add sign-in, run it at the edge

Skip this phase if the site is fully public. EDS site authentication has one token for the whole site and no per-user rules, so signed-in features (an account page, bookings, gated downloads) need an edge worker in front of EDS. The pattern below keeps every token out of the browser: the worker signs the user in, holds the session, and calls your account API on the user's behalf.

sign-in at the edge · browser, worker, IdP, account API, EDS

The browser only ever talks to the worker. The worker alone talks to the identity provider and your account API; public pages pass through to EDS unchanged.

  • ☐ Name the layer that runs sign-in (Cloudflare Worker, Fastly Compute, Akamai EdgeWorkers, App Builder) in the design
  • ☐ Log in with the authorization code flow and PKCE; tokens are exchanged by the worker, never in the browser
  • ☐ Verify the ID token: pinned algorithm, keys from the IdP's JWKS, exact iss, aud = your client ID, exp, nonce, a maximum age
  • ☐ Keep the session server-side; the browser holds only a random ID in an HttpOnly cookie
  • ☐ Route protected requests to your API, never to EDS, without the user's cookie or any bearer token
  • ☐ Authorise every request, and mark per-user responses Cache-Control: private, no-store
  • ☐ Support logout and admin revocation

The worker. A Cloudflare Worker with a KV namespace bound as SESSIONS and secrets CLIENT_ID, CLIENT_SECRET, ACCOUNT_API and ACCOUNT_API_KEY. Take the endpoint URLs from your identity provider's discovery document (/.well-known/openid-configuration).

import { createRemoteJWKSet, jwtVerify } from 'jose';

const ISSUER = 'https://login.example.com/';
const AUTHORIZE_URL = `${ISSUER}authorize`; // from the discovery document
const TOKEN_URL = `${ISSUER}oauth/token`;
const LOGOUT_URL = `${ISSUER}logout`; // the IdP's end_session_endpoint
const jwks = createRemoteJWKSet(new URL(`${ISSUER}.well-known/jwks.json`)); // cached, follows key rotation
const SESSION_TTL = 8 * 60 * 60; // seconds

const b64url = (b) => btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
const sha256 = async (s) => b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))));
const readCookie = (req, name) => req.headers.get('cookie')?.match(new RegExp(`(?:^|;\\s*)${name}=([\\w-]+)`))?.[1];
const setCookie = (name, value, maxAge) => `${name}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
const sameOrigin = (req, url) => req.headers.get('origin') === url.origin;

// 1. Login: send the user to the IdP with state, nonce and a PKCE challenge
async function login(url, env) {
const state = random();
const verifier = random();
const nonce = random();
const back = new URL(url.searchParams.get('return') || '/', url.origin);
const returnTo = back.origin === url.origin ? back.pathname + back.search : '/'; // no open redirect
await env.SESSIONS.put(`login:${state}`, JSON.stringify({ verifier, nonce, returnTo }), { expirationTtl: 600 });
const to = new URL(AUTHORIZE_URL);
to.search = new URLSearchParams({
response_type: 'code', client_id: env.CLIENT_ID, scope: 'openid',
redirect_uri: `${url.origin}/auth/callback`, state, nonce,
code_challenge: await sha256(verifier), code_challenge_method: 'S256',
});
return new Response(null, { status: 302, headers: { location: to.href, 'set-cookie': setCookie('__Host-login', state, 600) } });
}

// 2. Callback: exchange the code, verify the ID token, start a session
async function callback(req, url, env) {
const state = url.searchParams.get('state') || '';
const pending = await env.SESSIONS.get(`login:${state}`, 'json');
if (!pending || readCookie(req, '__Host-login') !== state) return new Response('Login expired', { status: 400 });
await env.SESSIONS.delete(`login:${state}`); // each login state works once
const res = await fetch(TOKEN_URL, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code', code: url.searchParams.get('code') || '',
redirect_uri: `${url.origin}/auth/callback`, code_verifier: pending.verifier,
client_id: env.CLIENT_ID, client_secret: env.CLIENT_SECRET,
}),
});
if (!res.ok) return new Response('Login failed', { status: 401 });
const { id_token: idToken } = await res.json();
const { payload } = await jwtVerify(idToken, jwks, {
algorithms: ['RS256'], // pinned: never trust the token's own alg
issuer: ISSUER,
audience: env.CLIENT_ID,
requiredClaims: ['exp', 'iat', 'sub', 'nonce'],
clockTolerance: 30,
maxTokenAge: '10m',
});
if (payload.nonce !== pending.nonce) return new Response('Login failed', { status: 401 });
// the IdP's tokens end here: the session holds only who the user is
const id = random();
const session = { sub: payload.sub, roles: payload.roles || [], created: Date.now() };
await env.SESSIONS.put(`session:${id}`, JSON.stringify(session), { expirationTtl: SESSION_TTL });
const headers = new Headers({ location: pending.returnTo });
headers.append('set-cookie', setCookie('__Host-session', id, SESSION_TTL));
headers.append('set-cookie', setCookie('__Host-login', '', 0));
return new Response(null, { status: 302, headers });
}

// The signed-in user, or null: unknown, expired or revoked sessions all fail
async function currentSession(req, env) {
const id = readCookie(req, '__Host-session');
const s = id && await env.SESSIONS.get(`session:${id}`, 'json');
if (!s) return null;
const revokedAt = Number(await env.SESSIONS.get(`revoked:${s.sub}`)) || 0;
return s.created > revokedAt ? { id, ...s } : null;
}

// 3. Protected request: authorise, then call the account API, never EDS
async function account(req, url, env) {
const s = await currentSession(req, env);
if (!s) return new Response('Unauthorized', { status: 401 });
if (!s.roles.includes('customer')) return new Response('Forbidden', { status: 403 });
if (req.method !== 'GET' && !sameOrigin(req, url)) return new Response('Forbidden', { status: 403 }); // CSRF
const headers = new Headers(req.headers);
headers.delete('cookie'); // the session cookie stays at the edge
headers.delete('authorization'); // and no bearer token travels on
headers.set('x-user-id', s.sub); // overwrites anything the client sent
headers.set('x-internal-key', env.ACCOUNT_API_KEY); // proves the call came from this worker
const path = url.pathname.slice('/api/account'.length);
const upstream = await fetch(`${env.ACCOUNT_API}${path}${url.search}`, { method: req.method, headers, body: req.body });
const out = new Response(upstream.body, upstream);
out.headers.set('cache-control', 'private, no-store');
return out;
}

// 4. Logout: end the session here and at the IdP
async function logout(req, url, env) {
if (req.method !== 'POST' || !sameOrigin(req, url)) return new Response('Forbidden', { status: 403 });
const s = await currentSession(req, env);
if (s) await env.SESSIONS.delete(`session:${s.id}`);
const to = new URL(LOGOUT_URL);
to.search = new URLSearchParams({ client_id: env.CLIENT_ID, post_logout_redirect_uri: url.origin });
return new Response(null, { status: 303, headers: { location: to.href, 'set-cookie': setCookie('__Host-session', '', 0) } });
}

export default {
async fetch(req, env) {
const url = new URL(req.url);
try {
if (url.pathname === '/auth/login') return await login(url, env);
if (url.pathname === '/auth/callback') return await callback(req, url, env);
if (url.pathname === '/auth/logout') return await logout(req, url, env);
if (url.pathname.startsWith('/api/account/')) return await account(req, url, env);
} catch {
return new Response('Unauthorized', { status: 401 });
}
return fetch(req); // public pages go to EDS, as in Adobe's BYO CDN guide
},
};

Where protected requests go. Only /api/account/* needs a session, and the worker sends it to your account API, never to EDS: EDS pages are cached and identical for every visitor. The API accepts calls only with the worker's x-internal-key (or mTLS) and is not reachable from the internet otherwise, so it can trust x-user-id. The user's cookie and any Authorization header are dropped on the way. If a whole page must be gated, keep its private parts in the API; if you gate EDS paths at the worker instead, protect the live origin with the site token too, and cover every variant (.plain.html, .md, .json, query-index.json).

Login. The worker handles the authorization code flow with PKCE. The state value is single-use and bound to the browser by the __Host-login cookie, which stops login CSRF. The nonce ties the ID token to this login, and returnTo only ever points at this site. After verification the IdP's tokens are discarded: no access or refresh token is stored or forwarded.

Logout and revocation. Logout is a same-origin POST (a link someone else plants cannot sign the user out), deletes the session, clears the cookie and ends the IdP session, so the next login is not silent. To cut off a user everywhere, for example after an account is disabled or a laptop is lost, write the revocation time:

await env.SESSIONS.put(`revoked:${sub}`, String(Date.now()));

Every session created before that moment then fails. Sessions also end on their own after SESSION_TTL. Workers KV can take up to 60 seconds to reach every location; if revocation must be immediate, keep sessions in a Durable Object or a database.

Keep tests that are built to fail, so a library upgrade or config change cannot quietly weaken the checks:

Case

Expected

ID token with alg: none, from another key, HS256 signed with the public key, or changed after signing

rejected

ID token expired, issued more than 10 minutes ago, or with nbf in the future

rejected (expired 10 s ago: accepted, within tolerance)

Issuer https://login.example.com.evil.com/, audience of another app, missing or wrong nonce

rejected

Callback with an unknown or already used state, or without the __Host-login cookie

400

?return=//evil.com or ?return=/\evil.com

redirects to /

Account request after logout, after revocation, or with a forged x-user-id

401, or the forged header is overwritten

Account API called directly, without the worker's internal key

rejected by the API

POST /auth/logout or POST /api/account/* from another origin

403

 

Phase 6: Before go-live, harden the response

By now the site has its real tags, fonts, embeds and forms, so this is when to finish the Content Security Policy, add security headers and review third-party scripts. Start two to three weeks before launch: report-only mode needs real traffic.

  • ☐ Sign off the third-party script inventory
  • ☐ Keep the boilerplate's nonce script policy enforced
  • ☐ Run the full policy in report-only mode, then enforce it
  • ☐ Add the standard security headers
  • ☐ Re-run the Phase 3 checks against live

Review third-party scripts. Every script runs with full access to the page, so a compromised vendor is a compromised site. List them, and record each one's purpose, the data it can see, its owner and where it loads:

grep -rhoE "https://[a-zA-Z0-9.-]+" head.html scripts blocks | sort | uniq -c | sort -rn
find . -path ./node_modules -prune -o -name "*.min.js" -print

Load tags late (from delayed.js, or consented.js once the visitor consents), self-host pinned library versions, use Subresource Integrity for static CDN files, and limit who can publish in the tag manager as tightly as who can merge to main.

Keep the nonce script policy. Both boilerplates ship this in head.html:

<meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-aem' 'strict-dynamic' 'unsafe-inline' http: https:; base-uri 'self'; object-src 'none';"
move-to-http-header="true">

EDS replaces aem with a random value on each render and moves the tag into a response header (details). With 'strict-dynamic', scripts that a trusted script adds are trusted too, which is why tag managers work without listing their hosts. Scripts in any static HTML you add, such as 404.html, need nonce="aem" too.

Write the full policy and test it in report-only mode. The script policy stops injected scripts. It does not stop data leaving, your pages being framed, or forms posting elsewhere. For a site with Adobe Launch, the Web SDK, Google Analytics, Adobe Fonts, YouTube and a captcha:

default-src 'self';
script-src 'nonce-aem' 'strict-dynamic';
connect-src 'self' https://*.adobedc.net https://*.omtrdc.net https://*.demdex.net https://*.google-analytics.com https://www.google.com;
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline' https://use.typekit.net https://p.typekit.net;
font-src 'self' https://use.typekit.net;
frame-src https://www.youtube-nocookie.com https://www.google.com https://challenges.cloudflare.com;
frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none';
upgrade-insecure-requests;
report-uri https://csp.example.com/report; report-to csp-endpoint;

Build the host list from your inventory and the reports. Keep both reporting directives: browsers that support report-to ignore report-uri, and older ones only use report-uri. If authors view the site inside another tool's iframe, add that origin to frame-ancestors.

A <meta> tag cannot be report-only, so send the full policy as a header through the configuration service while the nonce policy stays enforced:

curl -X POST "$ADMIN/headers.json" -H "$AUTH" -H 'content-type: application/json' \
--data '{"/**": [
{"key": "Reporting-Endpoints", "value": "csp-endpoint=\"https://csp.example.com/report\""},
{"key": "Content-Security-Policy-Report-Only", "value": "default-src '\''self'\''; ... ; report-to csp-endpoint"}
]}'

headers.json is one object, so GET it first and always send the full list. Collect reports for one to two weeks across campaign pages and every tag-manager rule, add the legitimate hosts, and investigate the rest. When reports are quiet, rename the header to Content-Security-Policy and keep the reporting directives.

Add the standard security headers. EDS sends HSTS and the CSP and nothing else, so add these in the same headers.json, or at your CDN:

Header

Value

Why

Strict-Transport-Security

max-age=31536000; includeSubDomains (your own domain, at the CDN)

HTTPS only; add includeSubDomains only if every subdomain serves HTTPS

X-Content-Type-Options

nosniff

Stops browsers guessing file types

Referrer-Policy

strict-origin-when-cross-origin

Keeps full URLs off other sites

Permissions-Policy

camera=(), microphone=(), geolocation=(), payment=()

Turns off device APIs the site never uses

X-Frame-Options

SAMEORIGIN

Clickjacking protection for older browsers


Never add Access-Control-Allow-Origin: * to protected preview or live hosts: it exposes signed-in authors to cross-site request forgery.

On launch day, check the headers, re-run the Phase 3 checks against the live domain, and scan the site with a tool such as securityheaders.com:

curl -sI https://www.yourdomain.com/ | grep -iE 'content-security|strict-transport|x-content-type|referrer-policy|permissions-policy|x-frame'

Phase 7: After launch, keep it secure

Security on EDS drifts through small changes: a new sheet, a new tag, a new branch, a key nobody rotated. Put these on the team calendar.

How often

Task

Every pull request

Secret scan and lint pass; code owners review security files

Every week

Merge Dependabot updates

Every month

Review CSP violation reports; check the Admin API audit log for unexpected publishes or config changes

Every quarter

Review the third-party inventory, tag manager users, admin roles and GitHub access; re-run the Phase 3 and launch-day checks

Before expiry

Rotate Admin API keys and site tokens

When someone leaves

Remove them from GitHub, admin roles and the tag manager; rotate any key they held; revoke their sessions if they had a site account

 

Conclusion

EDS gives a new project a strong start: no server code to patch, a delivery tier that filters paths, and a nonce-based CSP out of the box. What it cannot decide for you is what you publish, who can reach preview, who can push to main or publish a tag, and which layer owns each check. Settle those in the first week, and most of this checklist is one-time setup rather than cleanup.

Sources