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


Every piece of the revalidation setup from [Part 1](/posts/xm-cloud-vercel-isr-part-1-revalidation-on-publish) and [Part 2](/posts/xm-cloud-vercel-isr-part-2-the-datasource-gap) - 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](/posts/xm-cloud-vercel-isr-part-1-revalidation-on-publish) - the automatic path: webhook receiver, Vercel settings, Edge webhook.
- [Part 2: The Datasource Gap](/posts/xm-cloud-vercel-isr-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:

```powershell
$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).

```powershell
$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 piece | Where it lives |
|---|---|
| The secret value | DB override of the SPE script item in that CM |
| The Edge webhook `secret` header | Edge webhook registration (where enabled) |
| Vercel `ISR_REVALIDATE_SECRET` | Vercel project env vars |
| The firewall bypass rule | Vercel 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.
