---
title: Magic Link Bridge cert binding + rotation runbook
description: Operational runbook for binding the Azure-Key-Vault-generated public cert to the Magic_Link_Bridge External Client App (inline base64 in GlobalOauthSettings, deployed via metadata). Also covers the post-launch cert-rotation cadence.
status: Active
owner: Kyle
updated: 2026-05-26
last_verified: 2026-05-26
verification_type: sandbox-test
verification_ttl_days: 30
---

# Magic Link Bridge cert binding + rotation runbook

> **Companion to:** [plan.md](./plan.md) §6 Phase 0 + §6 Phase 1 + §6 Phase 4 + §8.4.
> **Audience:** the production-build team, then ops post-launch.
> **Status (2026-05-26):** This procedure is **done + proven in the `ClaudeTest` sandbox** — the public cert is bound to the sandbox `Magic_Link_Bridge` ECA and the full mint → redeem → JWT-Bearer/`singleaccess` auth flow works end-to-end (Azure `Bridge_Success ×2`; SF `LoginHistory` 4/4 successful logins from the Azure NAT IP). What follows is therefore the **production** go-live procedure: repeat these steps against the prod org's ECA, then Azure reverts its 3 sandbox App Config values (`TokenEndpoint`, `SingleAccessEndpoint`, `EcaClientId`) back to prod.

## Why this runbook exists

The Magic Link Bridge JWT Bearer flow requires a signing cert whose **private key stays in Azure Key Vault** (per plan §3 + §8.1; Azure signs in-place via `CryptographyClient`) and whose **public cert is held by Salesforce** so the platform can verify Azure's signatures. Two facts about how that public cert is configured:

- **The public cert is bound INLINE — as base64 of the DER — directly in `ExtlClntAppGlobalOauthSettings.<certificate>`, deployed via the metadata API.** There is NO Setup-UI step, NO JKS keystore, and NO `Certificate`-and-Key-Management record involved. Salesforce stores only the public cert; it never needs (and never gets) the private key. This is the same mechanism a ConnectedApp's "Use Digital Signatures" cert uses (verified in-org against the `AWS_Utility` ConnectedApp, whose `<certificate>` is inline base64 of a public X.509).
- **Sequencing:** the Key Vault keypair must exist before the public cert can be bound. Plan Phase 1 (Azure infrastructure) precedes Phase 4 (Salesforce cert binding).

> **Correction (2026-05-26).** Earlier revisions of this runbook (and plan §6 Phase 0 gotcha #1 / §7.6) claimed the cert had to be imported via Setup UI "Import from Keystore" with a JKS, because Salesforce supposedly needed a keypair it held the private key for. **That was wrong** — it came from misreading the Track A failure, which was actually a _format_ error: `<certificate>` was set to a Certificate **record name** (`Magic_Link_Bridge`) instead of the cert's base64 **content**, so Salesforce reported "the uploaded file isn't a certificate." The inline-base64-public-cert mechanism was proven by deploying the real KV public cert to the `Magic_Link_Bridge` ECA in `ClaudeTest` on 2026-05-26 (verified: bound cert is byte-identical to KV `magiclink-eca-signing-prod`, SHA-256 `12:98:B2:F8…95:D6`, public-only).

## Production cert-binding procedure

Follow these steps on first production deploy (after Phase 1 has provisioned the Azure side).

### Step 1 — export the Azure-side public cert (keypair already exists)

The signing keypair is already provisioned in Azure Key Vault `advancedrxkeyvaulteast2` as cert `magiclink-eca-signing-prod` (Phase 1 ✓ built). You only need to export the PUBLIC half.

1. Download the public cert and base64-encode the DER (this is the exact string that goes in `<certificate>`):
   ```bash
   az keyvault certificate download \
       --vault-name advancedrxkeyvaulteast2 \
       --name magiclink-eca-signing-prod --encoding DER \
       --file magic-link-bridge.der
   base64 -w0 magic-link-bridge.der > magic-link-bridge.b64
   ```
   (Equivalently from a PEM: `openssl x509 -in cert.pem -outform DER | base64 -w0`.)
2. Sanity-check it decodes to the expected cert: `base64 -d magic-link-bridge.b64 | openssl x509 -inform DER -noout -subject -enddate -fingerprint -sha256`. There is no private key in this file — public cert only.

### Step 2 — bind the public cert inline on the ECA + deploy

The cert is bound as inline base64 in `ExtlClntAppGlobalOauthSettings.<certificate>` and deployed via the metadata API — no JKS, no Setup-UI import, no `Certificate`-and-Key-Management record.

1. Retrieve the current GlobalOauthSettings (so you edit the live field set):
   ```bash
   sf project retrieve start --target-org <prod alias> \
       --metadata "ExtlClntAppGlobalOauthSettings:Magic_Link_Bridge"
   ```
2. Add the `<certificate>` element to `force-app/main/default/extlClntAppGlobalOauthSettings/Magic_Link_Bridge.ecaGlblOauth-meta.xml`, with the **base64 content from Step 1** (NOT a Certificate record name — that's the Track A mistake):
   ```xml
   <certificate
   >MIIFUDCC…(single-line base64 DER from Step 1)…XTxKA=</certificate>
   ```
   (Insert immediately after `<callbackUrl>...</callbackUrl>` and before `<externalClientApplication>...</externalClientApplication>` per existing field ordering.)
3. Deploy:
   ```bash
   sf project deploy start --target-org <prod alias> \
       --metadata "ExtlClntAppGlobalOauthSettings:Magic_Link_Bridge"
   ```
4. Verify by re-retrieving and confirming the bound `<certificate>` base64 decodes to the expected cert (subject `CN=magiclink-eca-signing-prod`, matching SHA-256). If the deploy errors with "The uploaded file isn't a certificate," the `<certificate>` value is a name/garbage instead of base64 cert content.

### Step 3 — hand the ECA consumer key back to Azure

Azure's redeem path needs the prod ECA's consumer key as the JWT `iss`. **Redeem returns `ConfigurationMissing` until this is set.**

1. Retrieve the consumer key (client_id) via the Connect REST API:
   ```
   GET /services/data/v66.0/apps/oauth/credentials/{ECA_ID}/{CONSUMER_ID}?part=keyandsecret
   ```
   (Enable Setup → External Client Apps → Settings → "Allow access to consumer secrets via REST API" first — off by default.)
2. Set it as Azure App Config `MagicLink:Salesforce:EcaClientId` (label `prod`).
3. The consumer SECRET is NOT needed — JWT Bearer uses only the key as `iss`.
4. Confirm the other App Config values (label `prod`) point at the prod org:
   - `MagicLink:Salesforce:TokenEndpoint` = prod My Domain `https://<mydomain>.my.salesforce.com/services/oauth2/token`
   - `MagicLink:Salesforce:SingleAccessEndpoint` = prod Experience Cloud singleaccess endpoint (Azure auto-rewrites to the community base if the token response carries `sfdc_community_url`)
   - `MagicLink:Salesforce:JwtAudience` (optional) — leave unset to use the token-endpoint authority; set explicitly only if prod rejects the default.

### Step 4 — verify the JWT Bearer flow end-to-end

1. Have Azure's `fn-magiclink-prod-east2` mint a test JWT assertion (the Function App's `/healthz` endpoint or a one-off invocation).
2. POST the assertion to `/services/oauth2/token` per plan §7.1's `GET /r/{shortCode}` step 7. Confirm `access_token` is returned (not `invalid_grant`).
3. POST the access_token to `/services/oauth2/singleaccess`. Confirm `frontdoor_uri` is returned.
4. Follow the `frontdoor_uri` in a browser. Confirm session cookie is set + HTTP 200 on storefront page.

If step 1 fails with `invalid_app_access`, re-run the SetupEntityAccess post-deploy Apex for the target permset.
If step 2 fails with `invalid_grant`, the cert binding didn't take — re-verify Step 2 (the `<certificate>` value must be the cert's base64, and Azure must be signing with the matching KV key).

## Cert rotation (post-launch)

Rotation is a metadata redeploy of the `<certificate>` value — no JKS, no Cert-and-Key-Management record to delete.

1. Generate the new cert version in Key Vault `advancedrxkeyvaulteast2`. Download the public cert + base64 it (Step 1 above).
2. Update `ExtlClntAppGlobalOauthSettings.<certificate>` with the new base64 and deploy (Step 2 above).
3. Flip the Azure App Config setting `MagicLink:KeyVault:SigningKeyVersion` to the new key version so Azure signs with the matching key. **Deploy the SF binding and flip Azure together** — they must reference the same keypair at any instant.
4. Smoke test mint/redeem; watch `MagicLinkLoginEvents` for `Bridge_Failure_*`.

**Overlap window — simpler than a TLS cert.** The JWT assertions are minted fresh at click time (180-second lifetime), not pre-signed and stored, so there are no in-flight assertions signed by the old key to honor. The §8.4 "96-hour overlap" framing (which exists for the 72h _link_ TTL) does NOT apply to the _signing cert_ — SF→Azure cutover just needs to be coordinated (both flipped close together), not held open for days. If you want zero-gap rotation, note the ECA `<certificate>` holds a single cert, so the clean approach is a brief coordinated cutover during a low-traffic window.

**TLS cert lifespan industry note (per plan §6 Phase 0):** TLS cert lifespan drops to 200 days in March 2026. The JWT signing cert is NOT a TLS cert — it's a code-signing cert — so the 200-day rule does NOT apply here. The JWT signing cert and the magic-link Front Door TLS cert rotate on different calendars; track separately.

## Why the cert lives inline in metadata (not as a Certificate record)

For **inbound JWT Bearer verification** (Salesforce verifying an external signer), the public cert is stored as base64 content directly in the app's OAuth config — `ExtlClntAppGlobalOauthSettings.<certificate>` for an ECA, exactly as `<certificate>` on a ConnectedApp's `oauthConfig`. Salesforce holds the public cert only and never needs the private key. This is fully metadata-deployable and source-controllable (the public cert is not a secret).

Do NOT confuse this with two unrelated cert mechanisms:

- The **`Certificate` (Certificate-and-Key-Management) metadata type** is for certs where _Salesforce is the signer_ (mutual TLS, SAML signing, Named-Credential client certs, External Credential JWT Bearer). Those are keypairs SF holds the private key for — created via the UI or self-signed/CA-signed flows, NOT applicable here.
- Importing a JKS keystore (Setup → Certificate and Key Management → "Import from Keystore") is also for the SF-as-signer case (it loads a private key). The magic-link flow needs none of that — Azure is the signer; SF only verifies.
