Gated Content on Optimizely Graph, Part 1: The ACL Is the Gate
Graph filters by role for you. The hard part is everything around that check: drafts, ACL propagation, races, and content next to the gate.
Series: Part 1: The ACL Is the Gate · Part 2: Don’t Trust the Index Until It Has Caught Up (5 October) · Part 3: Pages, Languages, Visual Builder, and What Broke
Status: built and verified in September 2026 on CMS 13.1.2 and Optimizely.Graph.Cms 13.1.2 against a live Graph tenant, with .NET 10, Next.js 16 and @auth0/nextjs-auth0 4.30. Verified again on version 13.2.0 of the CMS and the add-on, released on 21 September: the database schema upgrades on start, every test passes, and the findings that depend on the add-on version held, with one reservation (threats to validity, item 8). Vendor claims link to vendor docs; everything else is either measured or a design choice, and says which. A CMS 12 note is in part 3.
The first gate I built opened itself. In my Alloy setup, registering the first CMS administrator, on the first-run page that AddAdminUserRegistration() adds, saved the root’s access rights, which on Alloy include Everyone:Read, down the whole tree. Nothing was published, no event I was watching fired, and every gated section was readable with the single key (Graph’s read-only key for public content). That gap was mine, not the CMS’s: my first version rewrote a section’s ACL only when the section was published, so an access-rights save that published nothing went straight past it.
In this part:
- why the real security boundary is the gated section’s ACL, wherever the role check runs;
- how ordinary CMS operations widen it, with nothing published;
- and how my implementation writes and repairs that ACL, one writer at a time, with the tests that show it holds.
The front end’s session, token refresh and cache come in part 2. If you have five minutes, start with Where this bites below.
Paragraphs marked [documented] rest on linked vendor docs, paragraphs marked [measured] on observations from my own setup, and paragraphs marked [my implementation] describe a design choice you may make differently. Unmarked paragraphs in the design sections describe my implementation too, except sentences that link vendor docs or name the versions they were measured on.
The gate that opened itself shows the pattern of the whole problem. The first question on a headless gated site is where the role check lives. On Optimizely Graph that is the easy part. Graph filters documents by their ACL: the single key reads only what everyone may read, and a request signed with the HMAC key (a key and secret for server-side calls) that carries the cg-username and cg-roles headers gets only what that user or those roles may read (single key, HMAC).
What exposes content is everything around the check.
Per the docs, the default synchronization mode (All) indexes drafts and previously published versions as well. Saving a folder’s access rights with “apply to all subitems” does what the docs say it does, even where a subitem had inheritance cleared, and in my tests copied them onto everything below, gated sections included, which opened gates without anyone publishing anything. A gated section is the block that holds the protected content. Created inline, in my tests it was stored as part of the page and had no ACL of its own, so its text rode along in the page’s public document. A shared block dragged into a gated section’s text, in my tests, kept the ACL of the folder it came from. None of these throws an error (which is the annoying part), and I would not call any of them a bug in the product: each follows from a documented default or an ordinary editor action, so I would not wait for an upgrade to change them; configuration and a few rules you add in the CMS close them.
I built the pattern below on an Alloy site on CMS 13, my own Graph tenant, a .NET BFF (backend for frontend; in this design an API that validates the visitor’s access token and alone queries Graph for visitors, while the session lives in Next.js) and a Next.js front end. Then I spent more time breaking it than building it. That was the useful part. This part is about getting the ACL right in the CMS: the content model, the order of writes, the repair, and the files and blocks next to a section. Part 2 follows the ACL into the index and on to the browser; part 3 covers CMS 13’s pages, languages and Visual Builder, what broke beyond the CMS side, and the full table of decisions and their cost.
Figure 1. The trust boundary: the BFF decides who asks, Graph enforces the section’s ACL, the gating module in the CMS keeps that ACL right.
Put simply: the BFF decides who the visitor is and which roles it sends to Graph, Graph enforces the content ACL, and the gating module in the CMS keeps that ACL correct.
Who decides what
| Question | Decided by | Based on |
|---|---|---|
| Is the visitor signed in, with a verified email address? | the BFF | the identity provider’s token |
Do they hold a role the content is for, e.g. GoldPartners? | Optimizely Graph | the block’s ACL in the index (the _rbac field in my tenant’s schema) and the roles in the cg-roles header |
| Do they meet a business condition, e.g. an active subscription? | the BFF | data in the token |
Only the middle row protects the content at the Graph boundary. Published content whose ACL lets everyone read it can be read by anyone who asks Graph with the single key, whatever the BFF decides afterwards. Confidential content needs a role, or at least a signed-in visitor (Authenticated in the ACL). The BFF does not assume Graph knows the CMS’s virtual roles: it adds Authenticated and Everyone to cg-roles itself, so every match rests only on the documented rule that an item is returned when its access list names a role sent in cg-roles (HMAC; part 2). A condition checked in the BFF can withhold content the ACL already keeps from the single key; it cannot protect content the ACL opens to everyone.
Figure 2. The full wiring, including the identity provider and the file path that skips Graph.
Only two components hold the Graph keys: the BFF, which queries with them, and the CMS, which indexes with them and, in part 2, checks the index with them. The single key is meant for published, public content, but even it stays in the BFF, so that every Graph call passes one cache, one call budget and one verdict. Treat it as a credential anyway, as the docs advise, and keep it out of logged URLs: in this BFF it travels in the query string. An anonymous visitor is served with the single key; a signed-in one with an HMAC-signed request carrying cg-roles and a fixed cg-username that names no one. The front end calls the BFF only from server code, and the access token stays in the encrypted session cookie. The front end renders the verdict the BFF returns for each gate, such as ALLOW or ACCESS_DENIED, and compares no roles. Files are the one path that skips Graph, and they get a section of their own below.
The BFF matters for security even though it is not the authoritative content filter: it picks the key, computes the roles and builds the cache key. [documented] Per the HMAC documentation, role filtering on an HMAC request needs cg-username and cg-roles together, and a signed request without role headers is equivalent to querying as a super user, so it returns every gated section. [my implementation] Sending both on every signed request is the BFF’s job, and that has tests of its own.
Two blocks per gate, because Graph filters whole documents
Graph’s documented access control decides, per content item, whether the whole item is returned at all (HMAC), and I know of no documented way to hide one field from part of the audience, so every gate is two blocks:
- a gate teaser, teaser for short: a public block with a headline, a short description and a reference to its section;
- a gated section, section for short: a shared block with the content and the audience roles, with its own, narrowed ACL.
Figure 3. One gate, two documents: the teaser travels with the page, the section is filtered on its own ACL.
Together they make a gate, and the section’s ACL is what closes it. The BFF asks for the page’s teasers, then for the sections by the keys in their references, and Graph decides from the ACL which sections come back. On CMS 13 a content reference is indexed as { key url } and no longer expanded, so the sections come from a second query. That is also what you want: each section is filtered by its own ACL, as a root document.
query AlloyTeasers($locale: [Locales], $skip: Int) {
GateTeaserBlock(
where: { _metadata: { status: { eq: "Published" } } }
locale: $locale
variation: { include: NONE }
orderBy: { _metadata: { key: ASC } }
limit: 100
skip: $skip
) {
total
items { GateKey Headline Text { html } Section { key } }
}
}
query AlloyGatedSections($ids: [String], $skip: Int) {
GatedSectionBlock(
where: { _metadata: { key: { in: $ids }, status: { eq: "Published" } } }
locale: [ALL]
variation: { include: NONE }
orderBy: { _metadata: { key: ASC, locale: ASC } }
limit: 100
skip: $skip
) {
total
items { _metadata { key locale fallbackForLocale } GateKey Content { html } }
}
}
The status filter is explicit because the docs say an HMAC request without role headers returns content whatever its publication status, and promise nothing narrower with them (HMAC). Sections are fetched in every language, so the BFF can tell a section the caller may not read from one that has not been translated; the latter gets “not available in this language”, but only for someone entitled to it. The answer has one item per section and language, and the BFF pages through total, 100 items at a time in a fixed order, for up to ten pages. If it stops short it logs a warning, because every section it did not read would look like a refusal. A fixed orderBy gives the pages a deterministic order, though not a snapshot; for consistent paging across many batches the docs suggest a cursor. But the set is small and bounded, the sections of one page in its languages, so I page with skip: a cursor would add a stateful sequence of calls to every page view.
For brevity, the first query takes every published teaser. My BFF reads them from the page’s own document instead, the page found by its URL and site; part 3 shows how.
A missing section is ambiguous: a refusal, a deleted section, a section not yet indexed and an empty reference all look the same, and the BFF, unable to tell them apart, answers each with a refusal. It counts verdicts by code in metrics and writes each one, with its gate, to the audit log, so a gate that never opens for anyone is one query away.
I started by linking the blocks with a free-text key, which meant a uniqueness check on every request, run as a signed request without role headers (what the docs call querying as a super user). A reference points at exactly one block. The check went away.
[my implementation] A section reaches a page only through a teaser. A block created inline was, in my tests, stored as part of the page, with no ACL of its own, and its text belonged to the page’s document. The placement rule, a validator that the publishing event enforces again, refuses to publish content that holds a section anywhere but behind a teaser: in a content area, in rich text (content personalized with visitor groups included), in a block property (a local block), as an inline block in a content area, or through any content reference but a teaser’s. Content nested deeper than 16 levels is refused outright. The rule does not recheck pages published before it was deployed.
Restrict before you publish, grant after
Editors never open the access rights dialog. They pick roles in a property on the section, and an initialization module writes the ACL. The CMS supplies the events, the access-rights API and the database; everything built on them below, from the sync to the lock, the marker, the repair job and the file tickets, is my implementation, not a CMS feature. The ACL belongs to the block, not to a version (in 13.1.2 and 13.2.0, one ACL per content item, shared by its languages and versions), and in my tests on 13.1.2 and 13.2.0 the add-on indexed the published version with the ACL as it stood at that moment. So the order of writes is the design:
Figure 4. The order of writes on create and publish: restrict before the version goes live, grant after.
- on create, inheritance is cut at once, so a block in a folder everyone can read never picks up
Everyone:Read; - before publishing (
PublishingContent), the ACL is narrowed to the roles in both the live and the new audience; - after publishing (
PublishedContent), it gets exactly the new roles.
The core of the write, trimmed. SectionRoles are the managed roles, the editorial groups that must keep their access through every rewrite, plus SearchIndexer; both are explained after the code:
var desired = block.AudienceRoleList().ToHashSet(StringComparer.OrdinalIgnoreCase);
if (RefusalReason(block) is not null)
desired.Clear(); // a refused audience (e.g. Everyone or Authenticated combined with roles, a reserved role name) is written as no audience
var current = database?.Acl ?? security.Get(link); // inside the lock: read from tblContentAccess
if (mode == SyncMode.NarrowOnly)
desired.IntersectWith(ReadableAudience(current)); // may take away, never grant
if (!NeedsRewrite(current, desired))
return false; // without this, our own save would trigger the repair forever
var acl = new ContentAccessControlList(link);
var editable = (IEditableSecurityDescriptor)acl;
editable.IsInherited = false; // nothing from the parent folder
if (current is not null)
editable.Creator = current.Creator; // Replace would drop the CreatorRole entry
foreach (var (role, access) in SectionRoles) // managed roles (Gating:ManagedRoles too), SearchIndexer
editable.AddEntry(new AccessControlEntry(role, access, SecurityEntityType.Role));
foreach (var role in desired) // the audience: Read
editable.AddEntry(new AccessControlEntry(role, AccessLevel.Read, SecurityEntityType.Role));
security.Save(link, acl, SecuritySaveType.Replace);
[my implementation] In this design, a published section’s audience may narrow but not widen; a wider audience is a new section. My first version allowed widening, through a fragile sequence that re-implemented, outside the CMS, checks that belong to its own publish path. The rule compares the new list with every published language version, because the ACL is shared across languages, and it compares role names alone. The role hierarchy plays no part: replacing SilverPartners with GoldPartners is refused although it narrows the audience. The rule errs on the safe side on purpose: a wrongly refused change costs the editor a new section, a wrongly allowed one opens a published section to readers its live version does not admit.
If a publish fails after the narrowing, the ACL is left narrower than the live version, never wider. Visitors whose role the failed version dropped lose access until the first repair after the “publish in progress” marker (see below) expires, two minutes after the narrowing; until then a repair may only narrow. In this design the outage ends at the first successful repair after the marker expires: at most about twelve minutes (two minutes of marker plus up to one ten-minute job interval), plus the reindex.
Editors get Read, Create, Edit, Delete and Publish on sections. A Replace write removes every entry it does not re-add, so your own editorial groups, approvers and regional editors among them, belong on the list of managed roles. In my implementation that list is configuration (Gating:ManagedRoles, a role and its access each), kept by every sync and every repair. Every write also re-adds SearchIndexer; that entry comes from the sync itself, and configuration has no say in it. [documented] The CMS 13 conventions guide says content that group cannot read is not indexed. [measured] In every run here, the 13.1.2 and 13.2.0 add-ons indexed sections without that entry, so on these versions I would not rely on removing SearchIndexer to keep content out of the index. [my implementation] So a section gets SearchIndexer: Read anyway, and the gates stay in the index if a later version applies the rule.
Self-repair, and the race with publishing
Publishing is not the only way an ACL changes. In my tests on 13.1.2 and 13.2.0, any save that propagates access rights to subitems (MergeChildPermissions) copied an ancestor’s entries onto its descendants, and if the ancestor had Everyone:Read, the sections received it and, until something rewrote their ACL, were readable with the single key. So an ACL save repairs what it can have changed: the section itself, the section whose folder holds the saved item, or, for a save passed down to subitems, every section under it.
In my implementation, application startup and a scheduled job every ten minutes check them all. A section whose ACL is right costs a read and no lock; one that has drifted is repaired under a lock. A run that repaired anything logs a warning and adds to gating.acl.repairs for sections and gating.acl.files_closed for files; a run that leaves a section unrepaired adds to gating.acl.repair_failures, and a scheduled one also fails in the job history. Every scheduled run adds to gating.acl.reconcile_runs, so a job that stops running shows as a flat counter, and that silence can raise an alert of its own.
Figure 5. Every path that changes a section’s ACL ends in the same repair.
Two writers by themselves would be fine. The trouble starts when one of them decides from state the other has already changed. Without coordination, the repair could overwrite a freshly narrowed ACL with an older, wider list, in two ways: a repair that read the section just before a publish and wrote just after it, and one that ran between PublishingContent and PublishedContent, while the old version is still live.
Figure 6. The race with publishing: without the lock the stale, wider audience wins; with it, the in-progress marker lets the repair only narrow.
[my implementation] One lock per section, around every read-then-write of its ACL that this module makes, closes both: a Monitor in the process and an application lock in the CMS database across instances. The Monitor is safe here because, on 13.1.2 and 13.2.0, the content and access-rights events ran synchronously on the saving thread; async code would need SemaphoreSlim. The access-rights dialog does not take the lock; the repair covers what it writes. The lock’s owner is the transaction. A session would not do, because a pooled connection’s session would outlive a failed release:
var transaction = connection.BeginTransaction();
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.Parameters.AddWithValue("@resource", resource);
command.Parameters.AddWithValue("@timeout", (int)timeout.TotalMilliseconds);
command.CommandTimeout = (int)Math.Ceiling(timeout.TotalSeconds) + 15; // longer than the lock wait
command.CommandText =
"DECLARE @result int; " +
"EXEC @result = sp_getapplock @Resource = @resource, @LockMode = 'Exclusive', " +
"@LockOwner = 'Transaction', @LockTimeout = @timeout; SELECT @result;";
// sp_getapplock does not throw on a timeout: it returns a negative number.
// The real code wraps this in an IDisposable: on any failure the connection is disposed,
// which ends the transaction, and Dispose() rolls it back to release the lock.
if (Convert.ToInt32(command.ExecuteScalar()) < 0)
throw new TimeoutException($"Lock {resource} not granted.");
// Release: transaction.Rollback().
Inside the lock the repair reads the section again, so it writes what is published at that moment; while a publish is in progress it may narrow but not widen. A publish takes the lock twice, before and after, waiting at most 30 seconds each time. A timeout before the publish cancels it; the version does not go live without the narrowing.
Inside the lock, the instance’s cache is not trusted either. DXP is Optimizely’s cloud hosting, where a site can run on several instances. They keep their caches consistent through remote events that broadcast a cache invalidation after one instance writes; my Startup calls AddCmsCloudPlatformSupport, which I rely on to configure them on DXP (not measured there, item 5). The docs give no delivery time for remote events, so I assume a window in which an instance could read a fresh ACL beside a stale cached section and write the wider audience back; I have not measured that window on DXP (threats to validity, item 5).
So inside the lock the section’s ACL comes straight from tblContentAccess, and for a repair its published audience from tblContentProperty; a publish takes its audience from the version it publishes. Those are internal tables, with no API contract, and reading them is a compatibility risk I took with open eyes, because a cached read can be behind the database exactly when it matters. That is the trade: API purity for fresher reads at the security boundary. The 13.2.0 schema change left the reads working, and they need re-testing on every upgrade. A test puts a newer state in the database than in the cache and checks that the repair writes nothing; without the database read it writes the wider audience.
One limit remains. The “publish in progress” marker lives only in the memory of the instance that publishes, so a repair on another instance in the window between PublishingContent and PublishedContent can briefly write the still-published audience back. PublishedContent narrows it again under the same lock, and the reindex after that write corrects the index.
Files, and the blocks next to them
Two channels sit outside the section’s ACL in a default CMS setup. Files: in my tests on 13.1.2 and 13.2.0, the CMS served /contentassets/... under the file’s own ACL, inherited from its folder, which on a default Alloy site lets everyone read it; that ACL did not follow the section’s. And a visitor signed in at the BFF is anonymous to the CMS, so no entry in that ACL could admit them anyway.
Shared blocks: in my tests, a block embedded in a section’s rich text was indexed as a Graph document of its own, with its own ACL, inherited, on a default Alloy site, from a folder everyone can read. On a site that indexes all block types, anyone with the single key could read its text, however narrow the section was.
My implementation closes both with five rules. Section text and files then follow two different authorization paths: text comes from Graph, filtered by the section’s ACL; a file comes from the CMS under its own ACL, and a gated one opens only with a ticket from the BFF.
Figure 7. The two channels next to a section, and the rule that closes each.
- Closed with the section. Everything in a gated section’s asset folder, files and blocks, at any depth, gets the section’s ACL minus its audience and minus
SearchIndexer(except the folder of a section whose audience isEveryone; a section stored in another section’s folder keeps its own ACL): the direct address stops working, and in Graph no visitor’scg-rolesmatches it, because only the managed roles remain in its ACL and the BFF sends only roles from its own role inventory. Content created, published or moved into the folder is closed on that event itself, nothing waits for the next repair, and a file in a subfolder opens with the section’s ticket like any other. - No foreign blocks. A section whose text embeds a shared block from outside its own folder is refused on publish, even when validation is skipped. The editor is told to move the block into the section’s folder.
- Opened by a ticket. After an ALLOW verdict, the BFF turns every
/contentassets/link into/gated-fileon the CMS with a five-minute ticket: an HMAC of the section, the path and the expiry, with a secret the BFF and the CMS share. The BFF makes a fresh ticket on every request, after reading its cached answer, so the BFF never caches a ticket. The front end’s cache for anonymous visitors does hold answers for sections open to everyone, tickets included, so its lifetime must stay well under the ticket’s (part 2). - Bound to the section. The CMS refuses a file outside the folder of the section the ticket names, so a ticket earned through one section opens nothing of another. For its five minutes, though, the ticket is a bearer token: whoever the link is forwarded to can use it, so keep it out of access logs; nothing in my code strips it. It also outlives a narrowing. The CMS checks three things, the signature, the expiry and the folder; who may read the section right now never enters into it. A page left open for longer than five minutes has dead file links until it is reloaded.
- Global files stay as they are. A file in the global folder may be linked from public pages, so it gets no ticket; if everyone can read it, the validator warns on publish.
Decisions in this part and what they cost
The choices above and their price. Part 3 has the full table, with the decisions of parts 2 and 3.
| Decision | Alternative | Cost | When to change it |
|---|---|---|---|
| two blocks per gate | one document with a hidden field | editors create two blocks; a validator enforces placement | not while Graph documents no field-level filtering, if Graph is to filter the content |
| audiences may only narrow | any change of roles | a wider audience is a new section; so is a move to another role | when widening becomes a firm business requirement that justifies the extra safeguards |
| ACL synced from a property | ACLs set by hand | a module, a database lock, self-repair, a job every 10 minutes | when only an administrator ever grants access |
| files and blocks in the section’s folder, closed with it | anything, anywhere | editors keep a section’s files and blocks in “For this block” | when the files are not confidential |
| a section lock in the CMS database | an in-process lock | two SQL locks per section publish; a publish may wait up to 2 × 30 s | when the CMS always runs as one instance |
| ACL and audience read from the database inside the lock | the CMS’s cached APIs | a deliberate dependency on internal tables, re-tested on every upgrade | when the CMS runs as one instance, where no other instance can leave the cache behind |
Implementation notes: configuration that keeps the design safe
| What | Value | Why |
|---|---|---|
Optimizely:ContentGraph:ContentVersionSynchronizationMode | PublishedOnly | the default is All: published, drafts and previously published versions. The CMS 12 add-on has a different key, ContentVersionSyncMode, with a documented default of DraftAndPublishedOnly; that page describes the CMS 12 add-on. In my setup the CMS 13 add-on bound All when the key was absent, and a test checks that. My startup guard stops the CMS with anything else, checking both the key and the options the add-on actually bound (IOptions<GraphCmsOptions> with ValidateOnStart) |
services.AddContentGraph() | after AddCms(), the order in the install guide’s example, and only when keys are present | in my setup on 13.1.2, calling it earlier threw at startup; the exception suggested that a CMS shell service it intercepts was not registered yet. In my setup on 13.1.2 and 13.2.0, starting without keys made the add-on’s startup call fail with 401 and stopped the application. Outside development, missing keys stop startup explicitly: narrowings would never reach the index |
| indexed types | an exclusion list | the CMS 13 docs describe only exclusions, no allow-list. A real site indexes its own types; my site restricts itself to pages, Visual Builder sections, the gate types and the container block that can nest a teaser, only when asked to (Gating:OnlyIndexGatedTypes) |
a property used in where | [IndexingType(IndexingType.Queryable)] | Default is excluded from filtering and sorting, so, in my generated schema, a where on it did not validate |
Operational notes
| What | Value | Why |
|---|---|---|
| the application host (Admin, Applications) | the host the CMS actually runs on | absolute file links in rich text are built from it. After a port change my index still held the old host, and the BFF rightly refused a ticket for a foreign address. The fix is a full synchronization |
| full synchronization | the “Optimizely Graph Full Synchronization” job | in my setup, event indexing picked up only what was published after it was switched on; the full synchronization job indexed the rest |
| Content Delivery API | closed to all but CMS administrators | Graph on CMS 13 is not based on it; the guard stays in case the site adds it |
Where this bites: five silent failure modes
Five failures, all of them silent. Nothing throws, the site keeps working, and the wrong people read the wrong thing, or the right people cannot. Three more, on the way from the index to the browser, are in part 2.
-
Under the default synchronization mode, drafts are indexed. You see nothing: drafts are not served to the single key, which returns published content only, but the docs say a signed request without role headers returns content whatever its status, and promise no status filter with them, so every signed query the BFF sends for signed-in visitors should filter on status.
ContentVersionSynchronizationModedefaults toAll(docs). SetPublishedOnlyand refuse to start without it, checking the options the add-on bound, not just the key. -
Saving access rights can open gates. In my setup, registering the first administrator, or an editor applying rights to subitems, pushed the ancestors’ entries,
Everyone:Readincluded, down the tree with no publish, as the docs describe for that option, so a gate wired only to publishing events never notices. Repair on every ACL save, at startup and on a schedule, and alert on repairs. -
An inline block had no ACL of its own in my tests. In every run here its text was part of the page’s document, readable by whoever reads the page. Refuse gated sections anywhere but behind a teaser, including rich text and personalized regions.
-
A shared block in a section’s text keeps its own ACL. In my tests it was indexed as a document of its own, so on a site that indexes block types the single key read it directly whenever its folder let everyone read. Close everything in the section’s folder and refuse blocks from anywhere else.
-
The ACL belongs to the block, not the version. When the ACL is synced from an audience property, as here, publishing a wider audience widens every language at once, because, in 13.1.2 and 13.2.0, the CMS keeps one ACL per content item, shared by its languages and versions. Narrow before publishing, compare against every published language, and let audiences only narrow.
From prose to proof
Before the numbers, one caveat. They come from one machine, one tenant and one person, over a few days. They show that the mechanisms work, and roughly how fast. About capacity they say nothing.
Environment: Windows 11 workstation; CMS 13.1.2 (13.2.0, tested from 26 September), BFF and Next.js 16 on the same machine; a real Optimizely Graph tenant indexed from that CMS; Auth0 for identity; dates 23–26 September 2026.
Does an ACL change with no publish reach the index?
[measured] The repair only works if an ACL save reaches Graph. I tested it on a teaser, which the repair leaves alone: remove Everyone with a plain ACL save, sample the single-key answer every 2 seconds until the teaser disappears, put it back, and wait again.
| Measurement | Result |
|---|---|
| teaser gone from the single-key answer after the ACL save | ≤ 2.4 s |
| teaser back after the ACL was restored | ≤ 2.4 s |
| first such event after a CMS restart | 99 s |
| a role injected into a section’s ACL and removed by the repair: queries that returned the section | 0 of 59, over 30 s |
Measured end to end, from the CMS save to the single-key answer, on 13.1.2; for 13.2.0, see threats to validity, items 7 and 8.
The known limitations page gives 1–3 seconds for event-driven sync of small changes, which is consistent. That an ACL save triggers the reindex at all is my measurement, not documentation.
Do the race fixes hold?
Each fix in this table has a test on a real CMS started in process on a fresh database, and a negative control: switch the fix off, and the test must fail. A test that also passes without the fix proves nothing; one of mine did. My first stale-cache test checked only the end state: without the fix the repair wrote the wider list, that write triggered another repair, and the second repair narrowed it back, so the test passed. It now checks the decision and every ACL write made during it.
Figure 8. A negative control: a test counts only if it fails without the fix.
| Fix | Test | Without the fix |
|---|---|---|
| section lock | lost update between repair and publish | fails |
| narrow-only while publishing | publish window | fails |
| exclusive, not shared, database lock | cross-instance lock | fails |
| scheduled job | a failed repair is retried | fails |
| everything in the section’s folder closed | block and subfolder file closed | fails |
| foreign blocks refused | section embedding an outside block | fails |
| ticket bound to the section | another section’s ticket | fails |
| a file in a subfolder opens with its section’s ticket | ticket for a subfolder file | fails |
| ACL and audience read from the database inside the lock | repair beside a stale cache | fails |
Threats to validity
- One machine. The front-to-BFF hop was loopback; the BFF-to-Graph hop was a real internet hop from one location. A BFF in another region, or behind more network, will see different numbers.
- One tenant, one region. Graph timings vary by region and load.
- Two-second sampling. 2.4 s is an upper bound: the sample a fraction of a second after the save still showed the teaser, the next one did not.
- Small n. One post-restart observation of 99 s; one run of 59 queries for the injected role.
- Not deployed to DXP. The remote-event window between instances is unmeasured.
- I wrote the tests myself. Negative controls, and a pass after every change in which I tried to break my own code, mitigate that; they do not remove it.
- The CMS and the add-on can change. The ACL-save reindex, the indexing of sections without a
SearchIndexerentry, and the reads oftblContentAccessandtblContentPropertyhold on 13.1.2 and 13.2.0; re-check them on every upgrade. Part 3 lists the findings about pages, languages and Visual Builder that depend on the version too. - The reindex has a tail. Measured end to end, from the CMS save to the single-key answer, so no single component is implicated: on 13.2.0 the runs I timed were around three seconds, but twice, right after a full verification run, the index showed the change only after more than two minutes, one of them more than five. The docs give 1–3 seconds for small changes; the cause of the tail is not established, and two occurrences are not a rate. Part 2 closes that window on the path through the BFF, while the BFF can reach the CMS, with an audience ledger (the CMS’s own list of who may read each section); for queries that bypass the BFF it bounds it with a job that checks the index against the CMS every ten minutes.
- No public harness. My code is yet not public, so nothing here can be re-run as it stands; Three checks you can run in ten minutes is the substitute, and each check in it rests on the documented contract. I might do that in part 2.
What broke on the CMS side
The tests above pass now. On the way there, trying to break each major change right after writing it found more than any test I wrote first:
- two regressions in my own “simpler” versions of the ACL code;
- a fix for global files after which public files linked from a section stopped opening, even for entitled visitors;
- before tickets were bound to the section, a ticket that opened another section’s file;
- and shared blocks in a section’s text, readable by anyone with the single key on a site that indexes every type.
But running against a real tenant found three more that tests without Graph keys could not see: the add-on’s registration order, queries shaped for a content model the CMS did not have, and a stale application host in file links.
And one the other way round: a comment in my own code claimed that a Patch of a live page could slip a section past the placement rule without a publishing event. A test said otherwise: on CMS 12.29 and 13.1.2, a Patch of published content raised PublishingContent, even with validation skipped, so the rule already applied to it. The comment was wrong. Measure before you believe anything, your own comments included.
One more I found in my own tests, and late. On CMS 13 none of my validators ran. CMS 12 finds IValidate<T> implementations by scanning; CMS 13 needs each registered with AddCmsValidator<T>(), as the docs say; I had missed it. On 13.1.2 and 13.2.0 one that is not registered is never called. Every test that said “refused by validation” passed anyway, because the rules on the publishing event refuse the same content with the same message. An editor got the refusal on publish, never on save. A test now asks the CMS’s own validation service for each validator’s message.
Three checks you can run in ten minutes
My code is not yet public, so here is what to check on your own site. Each check rests on the documented contract; none of them needs my code:
- Ask Graph with the single key for your gated section type. Only sections meant to be public may come back;
totalfor the rest must be 0. - Log the bound
ContentVersionSynchronizationModeat startup. Anything butPublishedOnlyputs drafts in the index. - After the last “apply to subitems” anywhere above them, read the ACL of a gated section with a restricted audience. It must not contain
Everyone.
What’s next
The gating module now keeps the ACL right, and puts it back whenever something else writes it. That is half the problem. Graph enforces a copy of that ACL, taken when the add-on reindexes the section, and the copy can lag the CMS: in my end-to-end runs on 13.2.0 it followed within about three seconds, but twice, right after a full verification run, it took more than two minutes. Part 2, out on 5 October, is about that gap and the path from the index to the browser: an audience ledger that holds Graph to the CMS through the BFF, a job that checks the index itself for every audience, the headers without which a signed request reads everything, where the access token lives in the front end and who refreshes it, and a front-end cache that must not serve a gate you have just closed.
The role check turned out to be the easy part. The ACL is the gate, and something has to guard it.
Series: Part 1: The ACL Is the Gate · Part 2: Don’t Trust the Index Until It Has Caught Up (5 October) · Part 3: Pages, Languages, Visual Builder, and What Broke
Further reading
All links checked on 2026-09-29; the CMS 13 documentation is still moving.
Optimizely Graph
- What content Optimizely Graph indexes
- Authentication, Single key, HMAC and the HMAC signing recipe
- Known limitations and workarounds
- Cursor
CMS 13
- Install CMS 13
- Indexing conventions and the Conventions API migration guide
- CMS 13 and 12 Graph comparison
- Applications, FAQs for CMS 13
- Events and event providers
SQL Server
Stack: EPiServer.CMS 13.2.0 and Optimizely.Graph.Cms 13.2.0 (the code was built on 13.1.2), .NET 10, SQL Server, Next.js 16, @auth0/nextjs-auth0 4.30, OpenTelemetry with Azure Monitor. Optimizely, Auth0 and Next.js are trademarks of their respective owners; this article is not affiliated with them.