Fail-Closed ‘View As’ for HR on SitecoreAI
Written by Derek DenHartigh October 2026
Architecture Overview: A Personalized, Sitecore-Configurable Benefits Guidebook "View As" Functionality for HR Users
A benefits specialist picks up the phone. The associate on the other end works in a different state, under a different union contract, with a different set of plans. The specialist opens the benefits guidebook to walk them through their options but can only see their own benefit options.
This is what can happen when your personalization is a little too effective. In order to prevent this sort of situation from happening, we designed a "view as" override, specifically for HR users at this organization. This is one feature from the intranet program we wrote about in building the largest healthcare intranet on Sitecore XM Cloud (SitecoreAI), built for a U.S. health system with nearly 200,000 associates: a Sitecore-configurable benefits guidebook that shows each associate only the benefits that apply to them. With this added functionality, HR can see the benefits summary for anyone at the organization to answer their questions.
Personalization, i.e. showing the right content to the right person, is the easy part. The hard part is handing one group of users the superpower to see everyone's version of the page, without leaking access to that sensitive information to any unauthorized users.
The 30-Second Version
- Identifying the user. At sign-in we pull each associate's market, union code and home ZIP from Oracle HCM (Human Capital Management) Cloud onto their session. Every gating decision reads from that profile.
- Determining whether they're HR. Two independent sources can grant it: the job function on their Oracle record, or membership in a Google Workspace group. Oracle owns the facts, Sitecore owns the policy, since business users author both the list of job functions that counts as HR and the list of qualifying groups. Emptying both lists revokes the feature with no deployment.
- Gating the functionality securely. Hiding the control in React is a convenience for the user, not a security boundary. Every request is re-checked in the Next.js API route, so a forged "View As" returns the sender their own content and drops a warning in the logs.
- Failing closed when something breaks. No failure can grant HR access, it can only remove one of the ways to get it. If every source is unreachable, nobody is HR. The worst case is an HR user temporarily losing the 'View As' control, never an unauthorized associate gaining it.
The Problem: One Guidebook, Hundreds of Audiences
Benefits at an organization this size aren't one plan that fits all. They vary by market (the regional business unit an associate works in), by union bargaining unit, and by service area.
Content authors build each guidebook page in SitecoreAI Page Builder using purpose-built Benefit Block components. Each block holds child items, and each child carries one or more Rule Presets that decide whether its content displays by market, by union, or by coverage area.
The head is Next.js 16 on the Pages Router with the Sitecore Content SDK. Each guidebook page is statically generated with ISR (Incremental Static Regeneration), so every visitor who opens that URL is served the same cached HTML document. That document is a shell: the layout, the components, and the datasource IDs that determine which Benefit Blocks belong on the page, but no benefit content itself.
Once the shell loads, each Benefit Block calls back to a Next.js API route, which resolves the signed-in associate's profile on the server and returns only the children whose presets match. Two associates on the same URL receive the same HTML and end up looking at completely different benefits.
That split carries most of the security model. A cached page is served to thousands of people across many pods, so anything baked into it would be readable by anyone. Keeping gated content out of the cached layer removes that entire class of problem, and it leaves exactly one thing between a user and someone else's benefits: an authenticated API call that we control.
It also sets up the constraint we had to solve for. If content is assembled per associate from their own profile, then nobody can see anyone else's version, including the people whose job is to answer questions about it.
Where the Audience Comes From
Associates sign in with Google through NextAuth. In the session callback we look up the associate's worker record in Oracle HCM Cloud's REST API using a client-credentials service account, select their primary employee assignment, and derive three values: market (business unit), union code (bargaining unit), and home ZIP.
Two decisions came out of that integration, and both are about what to do when the data is ambiguous.
Which record defines your audience. Workers can hold employee, contingent, and non-worker records at the same time, occasionally more than one of the same kind. The rule we settled on is that only the primary employee assignment counts. A contingent-only worker resolves to an empty profile and sees base content, which is a deliberate answer rather than a fallback.
What an empty answer means. A successful response carrying no rows is indistinguishable from "that person doesn't exist." The integration can't tell those apart, so it doesn't try to guess. An unresolvable profile is empty, it's never cached, and an associate with an empty profile matches no gated content. Ambiguity resolves toward showing less, never more.
The resolved profile is cached in Redis with a stale-while-revalidate pattern, using a one-hour soft TTL (Time To Live) and a two-hour hard TTL, keyed by person. That matters more than it sounds. NextAuth's database session strategy runs the session callback on nearly every request, and a single guidebook page triggers one API call per block. Caching the profile saves a pile of round trips, which makes the page faster and keeps load off the system of record.
Deciding Who Counts as HR
The business told us how to identify an HR associate: match a job classification field on the Oracle record against a list of HR values. Reasonable. That's the field that exists for this purpose.
Then we pulled the data and counted.
The field is populated on only one of the two job populations in the catalog. Corporate catalog jobs carry it, but thousands of facility-specific legacy jobs leave it empty, and plenty of real HR staff exist in those legacy job records. Worse, not one associate in the test environment held any of the catalog jobs the rule was matching against. The rule matched the client specification exactly, but wouldn't have identified anyone as being HR.
We tried a fallback that recovered the catalog value from the legacy code. It never produced a false positive, but it caught only a third of the HR jobs we could identify by name, which doesn't quite make the cut. A check that half-works is worse than one that visibly fails, because the people it misses get no access and no error to explain it.
What rescued the approach was a different field in the same payload. A human-readable job function sat right beside the coded one and was populated where the coded one wasn't, so we matched "on that instead, using a list of accepted values authorable in Sitecore.
Alongside it we kept the Google Workspace group list as a second, independent way in. It earns its place twice over: testers and stakeholders can reach the feature in higher environments where the mocking layer is switched off, and a specific person can be granted access the same day instead of waiting for an Oracle record to catch up with a role change or a new hire. Both lists are authored in SitecoreAI, on a Benefits Settings item only benefits site owners can edit:
- HR job functions: a delimited text field, normalized on both sides before comparing.
- HR override groups: a multilist of the same Google group items authors already use for page visibility.
The decision itself lives in exactly one function:
export function resolveHrDetermination(inputs: HrDeterminationInputs) {
const source: HrDeterminationSource =
inputs.oracleIsHr === true
? "oracle"
: inputs.googleGroupOverrideGranted === true
? "google-group"
: inputs.mockHrViewEnabled === true
? "mock"
: "none";
return { isHr: source !== "none", source };
}
isHr derives from source, so the two can't drift apart, and source rides along on the session. When someone asks why a user was or wasn't treated as HR, support can quickly review the user's session and see how the HR determination was made. No hard-coded defaults sit behind the authored lists, either. Empty both fields in Sitecore and HR access is revoked everywhere, with no deploy required.
The third input explained, and why it can't reach production
That mockHrViewEnabled branch is a development convenience with one hard rule: it cannot run in production.
Developing and testing any personalized feature with a multi-variate grid of properties (such as union code, market, and zip code) can be miserable if one is relying on the business to provide sample users for every possible permutation. So we built a mocking layer that lets a developer or tester put their own session into any combination of market, union code, ZIP, and HR status, then watch the guidebook respond. This decision turned a multi-day data request into a convenient set of dropdowns; however, letting something like this slip to production could entail a serious data security risk.
Three mechanisms keep it out, and they stack. The mocking panel and every diagnostic page are named *.dev.tsx, and the production build drops that extension from its page list, so those routes are never compiled into the production bundle. The endpoint that saves and reads a mock payload returns a 404 in production, before it checks who is asking. And the session callback refuses to read a stored mock record in production and logs a warning if it finds one, which is the guard that makes the mock flag unreadable rather than merely unused.
Where the "View As" Selection Lives
Our first working version applied a view by overwriting the HR user's stored profile. Pick a market, write that market onto the profile, let the existing gating do the rest. It demoed well, and it's the obvious first idea, which is exactly why it's worth describing.
It had a couple of problems. Nothing on the server checked whether the user was HR before honoring the change and a temporary view could leak into any other feature that reads the profile.
The deepest problem was what it did to the record. The profile is supposed to be a faithful copy of what Oracle says about you and it's behind every gating decision. Once a feature writes to it, the profile stops being that copy and becomes whatever was written last, with no way to tell the difference; your source of truth loses its authority. Picture an HR user switching to a view of a non-HR associate in another market. They've just overwritten the only record that says they're HR. The application has no idea they're HR anymore, and won't until something forces the session to refetch, like a cache expiry or signing out and back in. The feature would undermine the very flag that authorizes it.
So we replaced it with:
- Browser sessionStorage, scoped to the guidebook. The 2026 and 2027 guidebooks keep separate selections, and closing the tab ends the view. Nothing persists on the server.
- A canonical form. Values are trimmed, de-duplicated, sorted, and capped, so two equivalent selections produce the same query string. That string becomes part of the SWR cache key, which keeps the client cache honest. (SWR here is the React data-fetching library, named after the same stale-while-revalidate pattern we use in Redis.)
- "Applies to" tags. When a view is active, each matched item shows which market or union it matched on, so HR can tell at a glance why a block is on the screen.
The split is what makes it safe. Identity is derived from Oracle on every session, and the application only ever reads it. The view is a request parameter the browser supplies and the server re-checks on every call. One is a fact we don't get to edit, the other is a preference that expires when the tab closes. The first version kept them in the same record.
Failing Closed, Layer by Layer
"Fail closed" is easy to say and easy to break. Here's what it meant in practice:
| What fails | What happens |
|---|---|
| Oracle worker lookup | Empty profile, not cached. The associate sees base content and isn't HR through Oracle. |
| Oracle job lookup | Its own try/catch. The associate is non-HR via Oracle, but their market, union, and ZIP still apply. |
| Experience Edge read of the HR settings | Empty lists. Nobody is HR via groups, and the Oracle job function can't match either. |
| A partial layout response | Never overwrites a good cached value with "nobody is HR." |
| The override code throws | Falls back to the remaining inputs. An outage can never grant HR, only fail to extend it. |
| Mock and test flags | Ignored when the environment is production. |
The rule underneath all six rows: every failure removes an input. No failure adds one.
The second half of the security model is the server-side gate on every benefits API route:
const requested = parseViewAsParams(req.query);
const selection = requested && isHrViewer(session) ? requested : null;
if (requested && !selection) {
log(
"WARNING",
COMPONENT,
"Non-HR session sent a view-as selection; ignoring",
);
}
The React component hides the control when the session isn't HR, but that's for usability. Anyone can type ?viewAsMarkets= into a URL. The API route ignores the selection, serves the caller their own content, and logs the attempt. The routes behind the dropdown options and the ZIP lookup tool return empty results to non-HR sessions, so the list of markets and unions never reaches an associate's browser in the first place.
Keeping HR-Only Data Out of Everyone's Page
During code review, someone suggested putting the market and union option lists into the Sitecore layout data, the way other site settings travel. It's the conventional place for them, and it would have been simpler to wire up.
Layout data rides along in that shared shell from earlier, so it reaches every visitor on every page. The full list of markets and unions would have sat in the page source of every associate who opened the guidebook, including the ones the control is deliberately hidden from, and those lists only grow (the ZIP collection alone holds thousands of items). It also wouldn't have saved the lookup: the loader runs in an API route, which can't read layout context, so it would still be querying Sitecore directly.
Instead, the options load on demand from an HR-gated API route that queries Experience Edge GraphQL through the Content SDK's clientFactory, sorts by label, and caches in Redis for 15 minutes. A failure returns empty lists and isn't cached, so a brief Edge outage doesn't pin empty dropdowns in place for the rest of the cache window. A label correction goes live when that cache expires, instead of waiting for pages to revalidate.
Caching, Without Caching the Wrong Thing
Most of the performance work here was deciding what may be shared between users and what may not:
| Layer | What's cached | Scope |
|---|---|---|
| ISR page HTML | Layout and datasource IDs only, no gated content | Everyone |
| Redis, 15 min | View-As option lists, site rules, ZIP sets | Shared reference data |
| Redis, 8 h plus a 60 s in-process memo | HR settings from Sitecore | Shared reference data |
| Redis, SWR 1 h / 2 h | Oracle profile | Per person |
| API responses | Cache-Control: private, no-store | Never shared |
| SWR client cache | Keyed by selection and user ID | Per browser |
Think of it this way. Anything personal is keyed by person or it isn't cached at all, and failures are never cached. An outage should end when the outage ends, not when a TTL runs out.
From Markets to ZIP Codes
Service areas came as a later requirement. The first design called for a mileage field on every benefit site, which would have put a per-market maintenance burden back on authors, exactly what the feature was supposed to remove. As the requirement evolved, it became plain membership in a shared ZIP list, imported into Sitecore with a Sitecore PowerShell Extensions script from a CSV already filtered to the service radius. An associate's home ZIP from Oracle either is or isn't in that list. "View As" gained a service-area dimension and an HR-only ZIP check tool, and both went through the same gate as everything else.
What We Actually Found
We expected the hard part to be the Sitecore modeling. It wasn't. Rule presets, branch templates, and authoring security all mapped cleanly onto SitecoreAI's tools. The hard parts were somewhere else:
- The source data. Our first rule for identifying HR matched the field the specification named. The data didn't support it, and we only knew because we measured thousands of real records instead of trusting a sample payload. A sample payload describes what someone believes the data looks like.
- The seams between layers. Each piece failed closed on its own. The risk lived where one layer's fallback became another layer's input, like a partial response overwriting a good cached value with an empty one.
- Where state lives. The first working version stored the right data in the wrong place. Moving the selection out of the stored profile addressed all of the requirements as well as patched a security gap in one change.
A Practical Checklist
- Decide who is privileged in one function, and make every route call it.
- Give each input the power to grant access only. Make every failure remove an input, never add one.
- Never trust a privileged flag from the client. Re-check on the server, then log and ignore anything that doesn't pass.
- Keep audience-specific option lists out of shared layout data and cached HTML.
- Don't cache failures, and don't cache a fallback that looks like a real answer.
- Put the kill switch in the CMS, with no code defaults hiding behind it.
- Measure the system-of-record data before you finalize the matching rule.
- Give testers a mocking layer, and make sure it cannot run in production.
What This Means for You
If your SitecoreAI site personalizes from an external system of record, whether that's an HRIS (Human Resource Information System), an ERP (Enterprise Resource Planning system), or a CRM (Customer Relationship Management system), sooner or later you'll need someone who can see past the personalization. Support staff. Compliance reviewers. Content owners. Getting that right isn't a big framework. It comes down to a handful of decisions about where the truth comes from, where state lives, and what each failure is allowed to do.
Planning personalization or role-based access on SitecoreAI and want a second set of eyes on how it fails? That's the kind of architecture review Nishtech does every day. Reach out and we'll walk your integration and caching layers with you.
_About the author: Derek DenHartigh, Sr. Developer, Nishtech._