Most Sitecore JSS tutorials wire a single API key straight into GraphQL calls made from the browser. That's a reasonable shortcut for a public demo site, where every visitor is meant to see the same content. It stops working the moment a project has both public content and role-gated content sitting behind SSO.
Because now two different trust levels need to talk to the same GraphQL endpoint, and only one of them is safe to hand to a browser. This article walks through a pattern used on a recent Sitecore XP 10.4 + JSS (Next.js) build with Azure AD B2C federated login: two API keys, each doing one job, and a Backend-for-Frontend (BFF) layer that keeps the more sensitive key on the server, permanently, no exceptions.
Why One Key Isn't Enough
A Sitecore GraphQL API key carries three things that matter for security: an impersonation user (the identity the request runs as when no logged-in user is present), an AllowedControllers list, and a CORS Origin restriction. Item-level security is evaluated against whatever user is active for the request, so a key backed by a narrowly-scoped impersonation user is safe to expose — anonymous visitors only ever get back what that user is allowed to read.
The trouble starts when one key has to do double duty: rendering public pages and also driving full-text search across the content tree. Search indexes are often built from a broader slice of content than any single page exposes, so the impersonation user behind a search-capable key tends to have wider read access than the one behind a plain layout key. Expose that key to the browser — embed it in a client bundle or a query string — and anyone with DevTools open can copy it and query the search index directly, sidestepping whatever access rules the application layer was enforcing.
Splitting the Keys by Trust Level
The fix is to stop treating “the API key” as a single thing. Two keys, two impersonation users, two different exposure rules:
| Key A — Layout / Public GraphQL | Key B — Search | |
|---|---|---|
| Purpose | Layout Service and page-level GraphQL queries the client needs directly | Full-text / faceted queries against the search index |
| Impersonation user | extranet\anonymous, or the authenticated visitor's own session | Dedicated service account scoped only to what the index should expose |
| Exposed to the browser | Yes — safe, because item security trims results per role | No — lives in server-side environment variables only |
| Called from | Client components / server-rendered pages via public config | A server-only Next.js API route (the BFF) |
| CORS Origin | Restricted to the app's known origins | Not applicable — only ever called server-to-server |
Architecture at a Glance
The browser only ever talks to the Next.js app's own origin. Public, role-trimmed GraphQL calls can go straight from the client using Key A. Search requests are routed through a dedicated API route that attaches Key B server-side before calling Sitecore — the browser never sees the search endpoint or the key that unlocks it.
Two request paths, two trust levels. Key A is safe in the browser because item security trims results. Key B never leaves the Next.js server.
The BFF Route
A Backend-for-Frontend route is just a server-side endpoint that sits between the browser and the real API so secrets and elevated-trust calls never cross that boundary. In this build, /api/search is that route: it accepts a search term from the client, builds the GraphQL query, attaches Key B from a server-only environment variable, and returns only the trimmed JSON the client needs.
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
const GRAPHQL_ENDPOINT = process.env.SITECORE_GRAPHQL_ENDPOINT!;
// server-only — note: no NEXT_PUBLIC_ prefix
const SEARCH_API_KEY = process.env.SITECORE_SEARCH_API_KEY!;
export async function GET(request: NextRequest) {
const term = request.nextUrl.searchParams.get('q') ?? '';
const query = `
query Search($term: String!) {
search(term: $term) {
results { items { item { name url { path } } } }
}
}
`;
const scResponse = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
sc_apikey: SEARCH_API_KEY,
},
body: JSON.stringify({ query, variables: { term } }),
});
if (!scResponse.ok) {
return NextResponse.json({ error: 'Search failed' }, { status: 502 });
}
return NextResponse.json(await scResponse.json());
}On the client, the component only ever calls the app's own route:
// client component
const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
const data = await res.json();The browser's Network tab shows a request to /api/search on the app's own origin. It never sees SITECORE_GRAPHQL_ENDPOINT or SEARCH_API_KEY — those values exist only in the Node.js process running the route handler.
Wiring the Keys on the Sitecore Side
Each key is its own item under the GraphQL API Key location in the content tree, with three fields that do the actual enforcement:
- Impersonation User — the identity the request runs as when there's no logged-in visitor. Set this to a narrowly-scoped account for Key B, not an admin or a broadly-provisioned one.
- AllowedControllers — restricts which GraphQL controllers/operations the key can reach.
- CORS Origin — for Key A, lock this to the app's actual origins. Key B doesn't need a CORS entry at all, since it's never called from a browser context.
Field- and template-level filtering for the schema itself is configured separately, in Sitecore.Services.GraphQL.Content.config, which lets you exclude system fields (like __Layout) or entire template groups from what either key's schema can return — a second layer of trimming on top of item security.
Layering Authenticated Users on Top
Anonymous visitors aren't the whole story. Once Azure AD B2C federated login is in the mix, Sitecore GraphQL calls need to reflect the logged-in visitor's own role, not just Key A's anonymous impersonation user. Sitecore resolves this in one of two execution contexts: the impersonation-user context, which applies until a request carries no valid auth cookie, or the logged-in-user context, which applies once a valid Sitecore auth cookie is present. Whichever context is active, item security is evaluated against that user — which is why role-based restrictions defined once in the Security Editor apply automatically to GraphQL results.
An API key's impersonation user overrides a visitor's own session. When a request arrives with ?sc_apikey=<guid>, Sitecore sets the context user to that key's impersonation user for the entire request — even if a valid .AspNet.Cookies header is also present. Protected items then vanish from results even for authenticated, properly-permissioned visitors. The security-trimming mechanism isn't broken; the wrong user is simply in context. The fix is to reserve sc_apikey-driven calls for genuinely anonymous traffic, and to forward the visitor's own auth cookie — not a key — on any request that should run as that visitor.
At a high level, the federated login flow that puts a visitor into the right role looks like this:
How a visitor ends up in the right Sitecore role, so GraphQL results are trimmed against their identity instead of an API key's impersonation user.
- Start loginBrowserNext.js App
GET / (visitor clicks “Sign in”)The app responds with a 302 redirect to the Sitecore CD federated-auth login endpoint.
- Redirect to CDBrowserSitecore CD
GET /sitecore/loginThe browser follows the redirect to the CD instance's login endpoint.
- OWIN challengeSitecore CDAzure AD B2C
Redirect: OIDC challenge (authorise)The CD instance issues an OWIN challenge against the Azure AD B2C identity provider.
- B2C authenticationBrowserAzure AD B2C
GET / POST /b2c/authorizeThe visitor authenticates at B2C, which redirects back to the CD instance's OIDC callback with a token.
- Virtual user & cookieSitecore CDBrowser
Redirect to Next.js with code (sc_code)OWIN creates a virtual extranet user and assigns a role (for example extranet\Reporters) from token claims, then writes .AspNet.Cookies for the CD domain and mints a short-lived, single-use code.
- Server-to-server exchangeNext.js AppSitecore CD
POST /sitecore/exchange-code (shared-secret header)The Next.js callback route exchanges the single-use code for the Sitecore auth cookie value.
- Re-home & finaliseNext.js AppBrowser
Set-Cookie on the app's own domain → redirectThe cookie is re-homed as a same-site cookie on the app's domain, and the visitor is redirected to the original destination.
- Subsequent callsNext.js AppSitecore CD
Layout GraphQL (cookie) · /api/search (Key B)Layout and GraphQL calls now carry the visitor's cookie, so Sitecore evaluates them as the authenticated virtual user. The search route keeps using Key B regardless of login state.
Subsequent Layout Service and GraphQL calls from the app include that cookie, so Sitecore evaluates them as the authenticated virtual user and role — not the anonymous impersonation user behind Key A. The /api/search route keeps using Key B regardless of login state, since the search index's access model is managed independently from page-level content security.
Verifying Nothing Leaks
Before calling this pattern done, check it the way an attacker would:
Open DevTools → Network, perform a search, and confirm the request goes to the app's own origin (/api/search), never directly to the Sitecore GraphQL endpoint.
Search the built client bundle (.next/static) for the search key's literal value — it should not appear anywhere in shipped JavaScript.
Confirm the search key's environment variable does not use the NEXT_PUBLIC_ prefix — Next.js inlines anything with that prefix into the client bundle at build time, by design.
Check server-rendered props (getServerSideProps output, RSC payloads) for accidental leakage — it's easy to spread an entire server config object into page props by mistake.
Rotate the search key periodically and confirm the app picks up the new value from environment configuration alone, with no code changes required.
Where This Breaks in Practice
Prefixing the search key with NEXT_PUBLIC_ “just to test something locally” and forgetting to revert it before merging.
Calling Sitecore GraphQL directly from a client component “for just one query” because the BFF route doesn't exist yet for that case — the one-off exception becomes the leak.
Reusing Key B's impersonation user for an unrelated feature later, quietly widening its read access — and with it, everything the search index can surface.
Assuming HTTPS makes a client-embedded key acceptable. It protects the key in transit; it does nothing to stop it being read straight out of DevTools or the shipped JS bundle.
Takeaways
Splitting one API key into two — one safe to expose because item security trims it, one kept strictly server-side because it isn't — is a small change with an outsized effect on the security model. Paired with a BFF route for the sensitive key and role-aware GraphQL calls for the authenticated path, it's the same trust-boundary pattern used everywhere else in a modern web app, applied consistently to Sitecore's GraphQL layer instead of carved out as a special case.
Frequently Asked Questions
Why isn't one Sitecore GraphQL API key enough?
A single key has to serve two different trust levels once a site has both public content and role-gated content. A search-capable key usually sits behind an impersonation user with broader read access than a plain layout key, because search indexes are built from a wider slice of content than any single page exposes. Expose that key to the browser and anyone with DevTools can query the index directly, sidestepping application-layer access rules.
What is a BFF (Backend-for-Frontend) route in Next.js?
A server-side endpoint that sits between the browser and the real API so secrets and elevated-trust calls never cross the client boundary. In the App Router that is a route handler such as app/api/search/route.ts: it accepts input from the client, builds the GraphQL query, attaches the server-only key from an environment variable, and returns only the trimmed JSON the client needs.
Why do protected items disappear for logged-in users when using sc_apikey?
An API key's impersonation user overrides the visitor's own session. When a request carries ?sc_apikey=<guid>, Sitecore sets the context user to that key's impersonation user for the whole request — even if a valid .AspNet.Cookies header is present. Security trimming isn't broken; the wrong user is in context. Reserve sc_apikey calls for anonymous traffic, and forward the visitor's cookie for requests that should run as that visitor.
Which Sitecore API key fields control security?
Each key is an item under the GraphQL API Key location with three enforcing fields: Impersonation User, AllowedControllers, and CORS Origin. Schema-level field and template filtering is configured separately in Sitecore.Services.GraphQL.Content.config, which adds a second layer of trimming on top of item security.
How do I verify the key isn't leaking to the browser?
Confirm search requests hit your own origin in DevTools; grep the built client bundle (.next/static) for the key's literal value; confirm the variable has no NEXT_PUBLIC_ prefix; inspect server-rendered props and RSC payloads for accidental spreading of a server config object; and rotate the key to prove it is read from environment configuration alone.
Related reading
- Delivering Successful Sitecore Projects: A Practical ChecklistWhere security reviews fit in the delivery lifecycle
- Inside Scrunch's Agent Experience Platform: A Technical Deep DiveHow agent-facing platforms change the Sitecore stack
- Sitecore Acquires Scrunch: What It Means for Sitecore CustomersRoadmap context for teams planning headless builds

Sitecore engineer working across Sitecore AI, Sitecore XP, headless JSS on Next.js, and federated identity — bringing AI-driven personalization and search into enterprise builds, with a focus on getting trust boundaries right in composable architectures.

