Skip to content

Sitecore XM Cloud + Vercel ISR - Part 3: The Secret

·8 min read

Every piece of the revalidation setup from Part 1 and Part 2 - the publish webhook, the manual SPE buttons - authenticates with a shared secret. Three rules on it, none negotiable: one identical script body in source control, the real value supplied per environment and never committed, and rotation that does not need a code change.

Sounds routine. It was not. What follows is mostly a tour of what did not work, because on XM Cloud each of the obvious approaches walks straight into a wall. If you ever wire an SPE script to a per-environment secret, treat this as the map of where the landmines are buried.

This is the final part of a three-part series:

  • Part 1: Instant Publishes - the automatic path: webhook receiver, Vercel settings, Edge webhook.
  • Part 2: The Datasource Gap - the workflow the automatic path cannot see, and the manual bridge.
  • Part 3: The Secret (this post) - four approaches to a per-environment secret, and the one that stuck.

Approach 1: Config Setting + XM Cloud Env-Var Override - Did Not Work

The tidy version: define an empty <setting name="Vercel.ISR.RevalidateSecret" value="" /> in a committed config patch, read it in the script with GetSetting, and fill it per environment with an XM Cloud Deploy variable named SITECORE_Vercel_dot_ISR_dot_RevalidateSecret (the documented SITECORE_<setting-with-dots-as-_dot_> convention).

It deployed cleanly, but GetSetting came back empty. That override populates Sitecore's built-in settings; it did not fill our custom setting. No error - just blank.

Approach 2: Inject the Value at Build Time in the Pipeline - Did Not Work

Next idea: ship the config with a token (#{IsrRevalidateSecret}#) and have the Azure DevOps pipeline replace it from a secret variable just before deploying.

The deploy succeeded and the literal token shipped - GetSetting returned #{IsrRevalidateSecret}# verbatim. The lesson here is the important XM Cloud bit: dotnet sitecore cloud deployment create builds from the environment's connected source-control branch, not the pipeline's working copy. The pipeline checks out the repo and edits a file, but the cloud build pulls the committed branch and ignores that local edit. (Tell-tale: the environment-connect health output literally says "deploy using Azure DevOps source control.")

Approach 3: A Sitecore Settings Content Item - Works, but Deferred

Read the secret from a dedicated content item via Get-Item, set per environment through the Authoring API, and never serialize it (so it stays out of git). This actually works and is the right long-term shape - but it needs a small template and item, and it was deferred under deadline. It is the tracked follow-up.

Approach 4 (Shipped): A Per-Environment Database Override of the Script

The committed script carries a placeholder (__SET_PER_ENVIRONMENT__) plus a guard that aborts if it is still the placeholder:

$secret = "__SET_PER_ENVIRONMENT__"       # = Vercel ISR_REVALIDATE_SECRET for this env
$bypassToken = "__SET_PER_ENVIRONMENT__"  # = Vercel deployment-protection bypass; empty on PROD
if ([string]::IsNullOrWhiteSpace($secret) -or $secret -eq "__SET_PER_ENVIRONMENT__") {
    Write-Host "[Vercel ISR] Secret is the committed placeholder - this CM's script was not overridden with the environment's real secret. Aborting."
    return
}

The real secret is set per environment as a database override of the script item in each CM - the Script field is edited directly via the Authoring API, creating a master-database copy of the item that shadows the deployed read-only copy. The secret lives only in each CM's database, never in git.

It is deliberate tech debt - the price of shipping on time - and it has sharp edges, below.

The Sharp Edges (Each Cost Real Time)

Items-as-Resources vs a database copy. XM Cloud deploys serialized items as read-only "resource" items (IAR). If the same item also exists in the database, the database copy wins and silently shadows the resource. This bit us both ways:

  • A leftover DB copy meant a deploy's updated script "did nothing" - the stale DB copy kept running. Fix: dotnet sitecore itemres cleanup removes the DB duplicate so the resource surfaces.
  • Our shipped approach depends on a DB copy (it holds the secret), so the rule flips: do not run itemres cleanup on that path, and set the serialization module to CreateOnly so a ser push cannot overwrite the DB copy with the committed placeholder.

The BOM trap. Setting the Script field from a file written by PowerShell's Set-Content -Encoding UTF8 prepended a UTF-8 BOM. SPE then failed with 'function' is not recognized - choking on a stray U+FEFF before the first line. Worse, the Authoring API strips the BOM on read, so fetching the field back looked clean and hid the cause. Fix: write the value BOM-free, and strip a leading U+FEFF defensively before saving.

Large multiline values and npm.cmd. Pushing the whole script through a CLI as --set "Script=<~5,900 chars with newlines>" failed - the npm shim cannot pass a big multiline argument. Read the value from a file in your tool instead of passing it as an argument.

PROD's WAF challenges server-to-server calls. PROD had the Vercel Firewall OWASP Core Rule Set enabled (from an unrelated security finding). It challenged the script's POST /api/admin/revalidate with a JavaScript challenge page - which a browser solves but automation cannot. DEV and UAT had no WAF, so they worked while PROD silently did not. Fix: a path-scoped custom firewall bypass rule for /api/admin/revalidate (and /api/admin/webhook). It is safe because that endpoint is already secret-gated and only clears a cache - the SQLi/XSS rules are not protecting anything there.

YAML literal-block blank lines. The Script field serializes as a YAML literal block (Value: |); blank lines inside it must carry the block indent, not be zero-width. A truly empty line ends the block early and the Sitecore build fails with Invalid YAML map value (no : found). A standard YAML linter will not catch it; only the Sitecore build does. (And [master] in that build log is the Sitecore master database, not a git branch.)

Rotating the Secret (the Three-Consumer Lockstep)

The secret has up to three holders per environment, and they must match:

  1. The CM script (manual buttons) - in the shipped approach it holds the secret directly (the per-env DB override).
  2. The Edge webhook's secret header (the publish path) - where the webhook is enabled.
  3. Vercel ISR_REVALIDATE_SECRET - validates both.

Publishing itself never breaks - it does not involve Vercel. What breaks on a mismatch is the revalidation call: pages fall back to the background timer, Edge auto-disables the webhook after about 10 failures in a row, and the manual buttons return 401. So rotate all three together, then prove it with the accept-new / reject-old test: POST the new secret (expect {"revalidated":true}) and the old one (expect a 401).

$token     = "<vercel-personal-token>"
$teamId    = "<team_...>"
$projectId = "<prj_...>"
$newSecret = "<new-strong-secret>"
$headers   = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
 
# --- 1. Patch the value in place (preserves type + targets) -------------------
$envId = ((Invoke-RestMethod -Headers $headers `
  -Uri "https://api.vercel.com/v9/projects/$projectId/env?teamId=$teamId").envs |
  Where-Object { $_.key -eq "ISR_REVALIDATE_SECRET" }).id
Invoke-RestMethod -Method Patch -Headers $headers `
  -Uri "https://api.vercel.com/v9/projects/$projectId/env/$envId?teamId=$teamId" `
  -Body (@{ value = $newSecret } | ConvertTo-Json) | Out-Null
 
# --- 2. Redeploy so the new value bakes in ------------------------------------
$latest = (Invoke-RestMethod -Headers $headers `
  -Uri "https://api.vercel.com/v6/deployments?teamId=$teamId&projectId=$projectId&target=production&limit=1").deployments[0].uid
Invoke-RestMethod -Method Post -Headers $headers `
  -Uri "https://api.vercel.com/v13/deployments?teamId=$teamId&forceNew=1" `
  -Body (@{ deploymentId = $latest; name = "<project-name>"; target = "production" } | ConvertTo-Json) | Out-Null
Write-Host "redeploy started - wait for READY before verifying"
 
# --- 3. Verify: NEW accepted AND OLD rejected ---------------------------------
$host_ = "example.com"; $site = "<site>"
function Test-Secret([string]$label, [string]$s) {
  try {
    $r = Invoke-RestMethod -Method Post -Uri "https://$host_/api/admin/revalidate" `
      -Headers @{ "Content-Type" = "application/json" } `
      -Body (@{ url = "/en-us/"; secret = $s; siteName = $site } | ConvertTo-Json)
    Write-Host "[$label] accepted: $($r | ConvertTo-Json -Compress)"
  } catch {
    Write-Host "[$label] rejected: HTTP $([int]$_.Exception.Response.StatusCode)"
  }
}
Test-Secret "NEW (expect accepted)" $newSecret
Test-Secret "OLD (expect rejected)" "<old-secret>"

A note on Vercel variable types while we are here: an encrypted variable can be read back through the API; a sensitive one is write-only. Prefer sensitive for the real secret, and verify with the test above since you cannot read it back to compare.

Promoting Across Environments

The committed script is identical everywhere (the placeholder). What differs per environment is set out-of-band:

Per-environment pieceWhere it lives
The secret valueDB override of the SPE script item in that CM
The Edge webhook secret headerEdge webhook registration (where enabled)
Vercel ISR_REVALIDATE_SECRETVercel project env vars
The firewall bypass ruleVercel WAF custom rules (PROD only)

Keep a unique secret per environment; never reuse dev's in prod.

Wrapping Up

The short version of the whole series: the tidy config / env-var / build-time approaches do not survive XM Cloud's source-control builds and built-in-only setting overrides, so what shipped is a per-environment database override of the script. It works, it is honest about being tech debt, and every sharp edge (IAR shadowing, the BOM, the PROD WAF) is written down - with a tracked follow-up to lift the secret into a proper settings item when there is room to.

If you are wiring up anything similar, I hope this map of the landmines saves you the hours they cost us. And if you have found a way to make the SITECORE_..._dot_... override work for custom settings on XM Cloud, I would genuinely love to hear about it.