Skip to content

Writing

Securing Agentic Workflows — AuthZ Patterns for AI-Assisted Content Ops

Muhammad Ali Khan · 04 Nov 2026 · 06 Mins read

Securing Agentic Workflows — AuthZ Patterns for AI-Assisted Content Ops

The demo that worried me was not a jailbreak. It was a successful run. A marketer asked a workflow agent to "prepare the campaign pages," the agent created the right items under the right templates, and the job id in the log was the only reason we could see which credential had done it. The credential was an automation client whose token scope included xmcloud.cm:admin. The marketer does not have that role in Sitecore. The agent did, because the client did, and the client secret was sitting in a shared automation project named after the whole organization.

Sitecore's Agent API is explicit that calls follow the product's security and approval rules, and that a bad write can be reverted with the job id. I rely on both. Neither one shrinks a client you created with the broadest scope and then reused from every flow, every environment, and one developer's laptop. These are the authorization patterns I put in place before a second agent is allowed to write.


The credential is a principal, not a feature flag

An Organization Admin or Organization Owner creates an automation client in SitecoreAI Deploy, under Credentials, Environment, Automation. The client id and the client secret are shown once. I store them in the same secret manager we use for any other production integration, in an entry that names the environment and the workflow. I do not store them in the repository, in the agent instructions, or in a screenshot for the enablement deck. If the secret has already been pasted into a chat, I treat it as public and I create a new client.

The token request Sitecore documents is the client-credentials grant against https://auth.sitecorecloud.io/oauth/token, with audience set to https://api.sitecorecloud.io. The response carries access_token, expires_in of 86400 seconds, and a scope. The sample scope in the docs is xmcloud.cm:admin. I read that as a powerful default, not as a target. The token lasts up to 24 hours. Sitecore recommends caching it rather than requesting a new one on every call, and refreshing when a call returns 401. Caching is right. Caching it in a browser, in a NEXT_PUBLIC_ variable, or in a log line is not. The rendering app still has no business calling the Agent API. I said that in the Agentic Studio post. The security version of the same rule is that a public env var turns the automation principal into a secret the visitor can read.

A server-side cache that actually expires is the pattern I want:

type CachedToken = { accessToken: string; expiresAt: number };

let cached: CachedToken | undefined;

export async function getAgentToken(
  fetcher: (body: URLSearchParams) => Promise<{ access_token: string; expires_in: number }>
): Promise<string> {
  const now = Date.now();
  if (cached && cached.expiresAt > now + 60_000) {
    return cached.accessToken;
  }

  const body = new URLSearchParams({
    client_id: required("SITECORE_AGENT_CLIENT_ID"),
    client_secret: required("SITECORE_AGENT_CLIENT_SECRET"),
    grant_type: "client_credentials",
    audience: "https://api.sitecorecloud.io",
  });

  const token = await fetcher(body);
  cached = {
    accessToken: token.access_token,
    expiresAt: now + token.expires_in * 1000,
  };
  return token.access_token;
}

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is not set`);
  return value;
}

required failing closed matters more than the cache. A missing secret should stop the worker. It should not fall through to an anonymous call that fails open in a surprising way, and it should not fall through to a developer token somebody left in a personal env file on the build agent. One client, one environment, loaded from the host's secret store.


Separate clients, and do not share production with the experiment

I create the non-production client first and I point every new flow at it until the writes match the rendering contract. The production client is a different secret, created in the production environment, stored in the production vault, and absent from developer laptops. An Organization Owner who uses one client everywhere has built a single key that can rehearse a bad prompt against live content. The API is called in the production environment even for the documented REST surface, which makes "I thought this was dev" a weak control. The environment of the credential is the control I can actually check.

OAuth authorization-code apps are a different registration. Sitecore asks you to request that registration through Support and to name the scopes. The list they publish includes xmcloud.cm:admin, several personalize.* scopes, brief read and write, brand kit read and write, cmp.sites:read, and platform.tenants:list. I request the subset the integration will call. An app whose only job is to read brand kits does not also need personalization management. Support will not invent a tiny scope Sitecore has not published, so "least privilege" here means refusing scopes from that list that the integration does not use, and refusing to hide a client-credentials admin token inside a flow that should have been a user-delegated login.

The Marketer MCP server sits on top of the same API and acts with the user's permissions, inside the workflows the site already has. That sentence is the best control in the product and the easiest one to defeat with a personal access setup. A developer who connects an MCP client to production, using a Sitecore user that can publish, has delegated publish to whatever prompt they type. I allow MCP against non-production for people who already have that access, and I keep production content operations on a workflow agent whose client cannot approve its own publish. If the only available scope is broad, the compensating control is the workflow state, not a hope that the prompt will be polite.


Approval, revert, and the audit record

"The API enforces Sitecore security" means the client cannot do what Sitecore would refuse that identity. It does not mean the client has the marketer's role. If the automation identity can create items, it can create items. If the workflow allows that identity to submit or approve, it will. I put automation identities in a role that can create and edit in the drafts workflow state and cannot approve or publish. A person moves the item. The agent stops at the state we named in the flow.

Every write I care about logs the job id, the client id (not the secret), the flow name, the item ids, and the user who started the run. Sitecore documents GET on the job and on its operations, and POST /api/v1/jobs/{jobId}/revert to restore the prior state. Revert is the incident tool. It is not a substitute for a review step, and it is not guaranteed to be a complete undo of everything a later publish already pushed to Edge. I revert, then I check the item, then I publish only if a person still wants the result public. A revert of a job that already published is a content emergency with a CDN tail, which is the caching problem from yesterday's post. Security and cache meet at the publish button.

I also keep web_search, and any other tool that reads outside the tenant, out of flows that write fields. Research belongs in a standard agent a person is watching. A workflow that can both browse the public web and write a field will eventually persist a sentence nobody on the brand team sourced. The brand kit and the items already in the tenant are the sources I allow on a writing step.

Instructions are not an authorization boundary. A line in the agent prompt that says "do not publish" is a usability hint. The workflow state and the role are the boundary. I review the exported flow definition as configuration: which tools, which environment, which client. I review the role in Sitecore as the actual grant. A flow JSON in git that drifted from the client it runs as is a finding.


What I do when a flow is proposed

  1. Name the principal. Which automation client, which environment, who can read the secret, when it rotates.
  2. Name the grant. Which workflow states that principal can enter, and the explicit absence of approve and publish.
  3. Point the first runs at non-production and compare the written items with the rendering contract. Unknown renderings and unparsable fields fail the run in review even if Sitecore accepted the write.
  4. Log the job id. Practice one revert on a throwaway item before the flow is allowed to create real drafts.
  5. Keep MCP and chat-style agents on user credentials in non-production. Keep production writes on the workflow principal with the narrower operational role.

I am not trying to make agents harmless. I am trying to make their privileges look like the integration privileges we already know how to review. A client secret shown once, a token that expires, a role that stops before publish, and a job id that can roll a bad write back are the pieces Sitecore has actually given us. The pattern is to use all four, every time, and to treat a shared admin client as the defect it is.

Related notes