---
title: Magic-link portal login (Azure-led architecture)
description: SMS + email magic-link auth into the AdvancedRx B2B Commerce portal. Orchestration in the existing AdvancedRx Azure VNet; Salesforce-side surface = one External Client App record + one permset. 72-hour time-based expiry, one-click landing. Spike A verified 2026-05-18; Spike B soft-passed 2026-05-21.
status: Active
owner: Kyle
updated: 2026-05-21
last_verified: 2026-05-21
verification_type: sandbox-test
verification_ttl_days: 30
---

# Magic-link portal login (Azure-led architecture)

> **Status:** Pre-implementation. Spike A (CCP Login + JWT Bearer + singleaccess) **verified in `ClaudeTest` 2026-05-18**. Spike B (SFMC link wrapping) **soft-passed via production SFMC self-send 2026-05-21** — fragments + paths both survive SFMC delivery intact in test-send mode; real journey/triggered-send link-wrapping behavior deferred to Phase 6a A/B rollout for empirical verification (see §6 Phase 0 + §10 Risk #2). Lead-developer handoff for the Azure-led architecture.
> **Owner:** Kyle Salata (kyle@advancedrx.co).
> **Last updated:** 2026-05-21 (revision 5 — Spike B result folded in: test-send fragment + path delivery confirmed intact, AdvancedRx SFMC infrastructure facts captured in §4.5, four execution gotchas captured in §6 Phase 0).
> **Relation to the original Salesforce-only plan:** This document supersedes [`../magic-link-portal-login.md`](../magic-link-portal-login.md) (the original Salesforce-only architecture). That doc stays in the repo as the fallback record — if the Salesforce-side singleaccess spike (§5 Spike A) fails for Customer Community Plus Login license users, the pure-Salesforce path is the next-best option. Otherwise the Azure-led architecture wins on the merits and the older doc will be archived once this one ships.
> **Read together with:** `CLAUDE.md`, `.claude/rules/apex.md`, `.claude/rules/flow.md`, `business-context/integrations/sfmc.md`, `business-context/storefront-data-audit.md`, `business-context/workflows/patient-texting-refill-reminders.md`, `business-context/objects/Refill.md`, `business-context/objects/Account_Sig.md`. Companion architecture audit produced by Codex against the AdvancedRx Azure infrastructure repo (`advancedrx-document-integration`) — referenced inline where relevant.

---

## §1 Executive summary

V2 changes one big thing about the V1 plan: **the orchestration moves out of Salesforce and into the existing AdvancedRx Azure VNet.** Patients still get a refill-reminder SMS or email from SFMC; they still tap a short URL; they still land on the refill wizard already logged in. What's different is where the work happens.

In V1, the entire mint→bridge flow lived in Salesforce: AMPScript JWT minting in SFMC, a community-reachable Apex REST endpoint to store short codes, a public Experience Cloud landing page with an `@AuraEnabled` bridge controller, a Custom Object jti store, a Big Object audit archive, and a CMDT retURL allowlist. The Salesforce-side surface was wide and the plan documented a long list of compensating controls.

In V2, that whole orchestration plane moves to Azure. AdvancedRx already operates a HIPAA-compliant Azure VNet (Key Vault, SQL contact cache, App Insights, ADF) for the eFax integration and the Azure E-Script Integration — including a patient-matcher service that already mirrors Salesforce Contact data into the VNet DB. The magic-link feature plugs into that existing platform: a new dedicated Function App mints short codes, holds the ECA signing key in Key Vault, runs the Salesforce JWT Bearer + `singleaccess` exchange at click time, and redirects the patient's browser to Salesforce.

**What stays on the Salesforce side in V2:** the External Client App config. **That's it.** One metadata record. No Apex, no Named Credentials, no community-reachable endpoints, no Custom Objects, no Big Object, no LWC, no public Experience Cloud route, no CMDT allowlist, no AMPScript JWT minting in SFMC templates. The link expires by time (72 hours from mint), not by event — that single design choice deletes the completion-webhook surface and everything that depends on it. An optional admin-revoke tool can be added post-launch if operational need surfaces; not in scope for v1.

**Compliance posture.** The AdvancedRx Azure VNet operates under an existing Microsoft BAA covering PHI-adjacent processing (eFax + e-script + patient-matcher all run there today). Kyle is compliance counsel of record; sign-off chain for this architecture is internal. Audit retention moves to Azure SQL as the authoritative store, with App Insights handling scrubbed operational telemetry.

**Scope.** SMS + email refill-reminder magic links, both from v1. Other use cases (B2B storefront login from non-SMS/non-email surfaces, SCV callback authentication, manually-issued admin links) remain explicit non-goals.

---

## §2 Use case (unchanged from V1)

### Patient story — SMS

1. Sarah is a long-time AdvancedRx patient. Her prescription is due for refill.
2. SFMC sends Sarah an SMS via MobileConnect: _"Hi Sarah, your prescription is ready to refill. Tap to start: https://login.advancedrx.net/r/Kj3pQ9xMv7nR. Reply STOP to opt out."_
3. Sarah taps. Her browser hits Azure, runs the bridge handshake, and lands her inside the refill wizard inside the Salesforce portal. She's logged in.
4. She completes the refill in 2-3 taps.

### Patient story — email

1. Marcus prefers email. The same `JBSystemFlow_Refill_Reminder_c` flow that handles SMS for Sarah handles email for Marcus, gated by his communication preference.
2. SFMC sends Marcus a refill-reminder email rendered from an OMM template. The email body shows a "View Your Prescription" button (the underlying href is `https://login.advancedrx.net/r/Kj3pQ9xMv7nR`).
3. Marcus taps the button. Same Azure bridge, same authenticated landing on the refill wizard.

### What's NOT in scope (unchanged from V1)

- Magic links from non-SFMC surfaces (Service Cloud Voice, case-driven texting, manual emails from CSRs).
- New refill wizard logic. The existing wizard built on `Account_Sig__c` transient scratch state is the landing target.
- Replacement for the existing login. The DOB + email-verification-code login on the storefront stays. Magic link is additive.
- Other patient-portal entry points (account settings, prescription history). The link redirects to the refill wizard specifically.

---

## §3 Architecture

### §3.1 Why Azure for the orchestration plane

The V1 plan put the entire flow in Salesforce because the team didn't already have alternative infrastructure. By the time V1 was scoped, AdvancedRx had built out the Azure platform that the eFax integration, e-script integration, and patient-matcher service all run on. Reusing it for magic-link gives us:

- **Public ingress hardening** that's hard to get in Salesforce — Azure Front Door + WAF + per-IP rate limiting at the edge, not in Apex
- **A Key Vault-backed signing key** that never leaves the vault (the ECA private key is signed in-place via `CryptographyClient.SignAsync`)
- **Sub-millisecond SQL hash lookups** at click time, no governor-limit pressure, no Apex CPU cap
- **A patient identifier lookup pipeline** already in place — the contact cache means Azure resolves Contact → Salesforce User → Username at click time without round-tripping to Salesforce
- **Operational observability** via App Insights — better than `Error_Log__c` triage for a public-facing auth surface
- **Drastically reduced Salesforce-side attack surface** — V2 deletes the community-reachable Apex REST endpoint that V1 needed, which is the most security-sensitive piece of the V1 design

The Salesforce side is the system of record (users, orders, the wizard itself, session establishment); Azure is the integration plane (mint, store, bridge). That mirrors the platform direction AdvancedRx is already running with for e-script and eFax.

### §3.2 Two-clock design — why

Salesforce enforces a **maximum 3-minute lifetime** on the `exp` claim of a JWT Bearer assertion. Industry data shows 67% of users click within 5 minutes and 89% within 15 minutes — a 3-minute window on the link itself fails for most recipients, especially on email's longer click tail.

V2 solves this with two clocks on two different things:

- **Link clock — 72 hours.** The short code Sarah receives in her SMS/email is valid for 72 hours. Azure owns this timer — there's no Salesforce-side cap on the link's lifetime because Azure isn't subject to Salesforce's JWT rules. 72h gives generous breathing room for delivery delays (email queueing, SFMC throttling) and click latency (patients tap whenever they get to it).
- **Salesforce-login clock — 3 minutes.** The Salesforce JWT Bearer assertion that Azure mints at click time is good for 180 seconds — well within Salesforce's 3-minute cap. The clock starts when Sarah taps, so it's never the limiting factor.

The V1 plan had to invent a "Token 1 / Token 2" custom-JWT pattern to work around this — V2 doesn't, because Azure controls the long-clock side and uses an opaque short-code, not a signed token, as the link payload.

### §3.3 End-to-end flow

The diagram describes the canonical happy path. Edge cases (prefetch, replay, expired, revoked) are detailed in §7.

```
[SFMC — JBSystemFlow_Refill_Reminder_c]
    │
    │  Channel-specific send-time event fires:
    │   - SMS callsite renders MobileConnect template
    │   - Email callsite renders OMM email template
    │  (Channel gates per business-context/workflows/patient-texting-refill-reminders.md §4.1)
    │
    │  At template render:
    │   1. AMPScript HTTPPost2 to https://mint.advancedrx.net/api/magic-links
    │      Body: { contactId, channel, retPath, idempotencyKey }
    │      Auth: Entra OAuth client credentials (or HMAC fallback — §6 prereq)
    │   2. Receive shortCode in JSON response.
    │   3. Render link as https://login.advancedrx.net/r/{shortCode}.
    │
    ▼
[Patient device]
    │
    │  Receives SMS or opens email; taps link.
    │  ─── Channel-specific paths converge here ───
    ▼
[Azure Front Door — Standard tier + WAF]
    │
    │  Public hostname: login.advancedrx.net
    │  Per-IP rate limit; custom WAF rules (method allowlist, route allowlist,
    │  size limits, geo policy); TLS termination; FDID-protected origin.
    │
    ▼
[Azure Function App — fn-magiclink-prod-east2]
    │
    │  GET /r/{shortCode}  (one-click; no interstitial)
    │   1. Hash shortCode via HMAC-SHA256 with peppered key in Key Vault.
    │   2. Look up the row via integration_api.usp_ResolveMagicLinkForRedeem.
    │   3. Reject expired/revoked/completed/unknown links with a generic UX.
    │   4. Resolve ContactId → Salesforce Username from local cache.
    │   5. Build JWT Bearer assertion: iss=ECA client_id, sub=username,
    │      aud=community URL, exp=now+180s, jti=uuid.
    │   6. Sign assertion via Key Vault CryptographyClient.SignAsync —
    │      private key never leaves Key Vault.
    │   7. POST signed JWT to Salesforce /services/oauth2/token.
    │   8. Receive access_token.
    │   9. POST access_token to Salesforce /services/oauth2/singleaccess.
    │  10. Receive frontdoor_uri.
    │  11. Append audit event row in MagicLinkLoginEvents.
    │  12. Respond with 303 See Other → frontdoor_uri.
    │
    │  (Scanner clicks — Microsoft Safe Links, Gmail link checking,
    │   corporate gateways — also trigger this flow. Sarah's link stays
    │   valid because it's multi-use; scanner-created Salesforce sessions
    │   live only inside the scanner's ephemeral HTTP client and self-
    │   destruct when the scan job ends. See §3.6 for the trade rationale.)
    ▼
[Patient browser]
    │
    │  Follows 303 redirect to frontdoor_uri.
    ▼
[Salesforce frontdoor]
    │
    │  Salesforce establishes session; redirects to retPath (/refill).
    ▼
[Refill wizard — patient is authenticated]
    │
    │  (No completion webhook. The link continues to be valid until its 72h
    │   TTL lapses. If the patient taps it again within the window — from
    │   the same device or a different one — she lands in the wizard again.
    │   After 72h, click → "this link has expired" page with a link to the
    │   regular DOB+code login.)
```

### §3.4 Why `singleaccess`, not raw `frontdoor.jsp`

Raw `frontdoor.jsp?sid=ACCESS_TOKEN&retURL=...` puts the access token in a URL query parameter. That URL ends up in browser history (often device-synced), proxy logs, HTTP `Referer` headers, and browser caches. Unacceptable for PHI-adjacent access.

`singleaccess` (Summer '24, Release 250) takes the access token in a POST body and returns a one-time-use `frontdoor_uri` in JSON. The token never sits in a URL. Salesforce Mobile SDK 12.2+ migrated to this endpoint.

**V2 uses `singleaccess` exclusively.** Do not implement the legacy `frontdoor.jsp?sid=` pattern as a fallback. If `singleaccess` doesn't work for our Customer Community Plus Login license users (§5 Spike A), the feature pivots — it does not fall back to query-string token exposure.

### §3.5 Why External Client App, not Connected App

Spring '26 disabled new Connected App creation by default. New work goes through External Client Apps. Same JWT Bearer flow, same `singleaccess` path — just the modern container.

### §3.6 One-click design — no interstitial

V2 uses a **one-click flow**: `GET /r/{shortCode}` does the full bridge (lookup → JWT mint → Salesforce token exchange → singleaccess → 303 redirect). Sarah taps the link, her browser follows the redirect, she lands authenticated. No "Tap to continue" page in between.

**This is a deliberate trade.** An earlier draft of V2 specced an explicit interstitial (and Codex's audit recommended that path). Three options were evaluated:

| Option                                  | UX                                               | Scanner posture                                                                                                                                                             | Industry precedent                                            |
| --------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| **One-click (chosen)**                  | Best — feels like every other magic-link product | Scanner clicks generate ephemeral Salesforce sessions inside the scanner's HTTP client; sessions self-destruct when the scan job ends; Sarah's link still works (multi-use) | Slack, Substack, Medium, Notion, most consumer SaaS           |
| Auto-redirect ("Signing you in…" page)  | Indistinguishable from one-click                 | Defends against scanners that don't run JS; modern scanners (Defender sandboxing, advanced gateways) increasingly do run JS, so this protection erodes over time            | Some polished consumer apps                                   |
| Explicit interstitial (Tap to continue) | One extra tap                                    | Strictest — only real human gestures trigger redeem; cleanest audit log                                                                                                     | Some high-security apps; uncommon for refill / consumer flows |

**Why one-click wins for this use case:**

- The "worst case" of a scanner click is an ephemeral Salesforce session inside the scanner's sandbox that never leaves it. The session cookie isn't transmitted anywhere outside the scanner's HTTP client, and the scanner discards its state when the job finishes. This is not an exfiltration vector.
- The refill wizard's blast radius if a scanner-created session were somehow misused is bounded: re-order Sarah's _existing_ prescriptions to her _saved_ shipping address. No write access to clinical data, no new prescription authority, no payment method changes. Not bank-wire-level damage.
- Industry-standard magic-link products (Slack, Substack, Medium) accept the same trade. We're not departing from the norm.
- The audit log noise (scanner-induced `Bridge_Success` rows) is manageable with scanner classification at the WAF and event-type categorization (§7.2).

**What this trade is NOT:**

- Not a security weakening that affects Sarah's account integrity. Her link is one-time-mintable by SFMC, multi-use within 24h, and killed on completion / revocation / expiry.
- Not a free pass on Salesforce API volume. Scanner clicks DO consume Salesforce token + singleaccess API calls. See §10 Risk #17 for the volume estimate and mitigation posture.

**If the trade becomes wrong:** revert to the explicit interstitial. The Function App's `/r/{shortCode}` handler is the only place that changes; everything else (SQL schema, ECA config, completion webhook, audit pipeline) is unaffected. This is a single-handler revision, not an architecture change.

---

## §4 Repo + infra context

### §4.1 Azure side — what already exists

Codex audited the AdvancedRx Azure infrastructure repo (`advancedrx-document-integration`) and confirmed the operational pattern V2 plugs into:

| Asset                                             | What it is                                                   | How V2 uses it                                                                                                            |
| ------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| **Azure VNet (East US 2)**                        | Single-region VNet hosting eFax, e-script, patient-matcher   | V2's Function App lives in the same VNet, same private dependency posture                                                 |
| **Azure Key Vault `advancedrxkeyvaulteast2`**     | Existing secret/cert store, MI-only access                   | Holds the ECA signing key/cert for V2; non-exportable; signed in-place                                                    |
| **Azure SQL DB `advancedrx_integration`**         | Authoritative state for eFax + e-script + contact cache      | Houses V2's `MagicLinkLogins` + `MagicLinkLoginEvents` + `SalesforcePortalUserCache` tables                               |
| **Contact cache (`sfdc.SalesforceContactCache`)** | Mirrors Salesforce Contact data via ADF nightly job          | V2 reads ContactId → User mapping locally without Salesforce round-trip at click time                                     |
| **App Insights**                                  | Centralized telemetry with PII scrubbing already established | V2's Function App emits scrubbed operational telemetry here; SQL holds the authoritative audit                            |
| **Entra app registrations + Managed Identity**    | Established pattern for inbound + outbound auth              | V2's `MagicLink-API` audience for SFMC→Azure auth; MI for Function→KV/SQL/Salesforce                                      |
| **eFax Function App `fn-receive-ep1`**            | .NET 8 isolated Functions on EP1 + VNet integration          | Reference operational pattern; V2 builds a **separate** Function App in the same VNet (different threat model — see §4.3) |
| **Patient-matcher service**                       | Uses the contact cache to match incoming records to Contacts | V2 reuses the same contact cache; new sibling `SalesforcePortalUserCache` table extends the pattern to portal Users       |

### §4.2 Salesforce side — what already exists that helps

| Asset                                                                                                         | Relevance to V2                                                                                                                                                                                                        |
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JBSystemFlow_Refill_Reminder_c` (SFMC)                                                                       | The existing flow that fires refill-reminder SMS + email. Magic-link work hooks into both callsites by editing each channel's template (add AMPScript `HTTPPost2` call to Azure mint). No new flow.                    |
| B2B Commerce storefront (`Advanced RX Store` on `vforcesite`, served at `https://advancedrx.net/vforcesite/`) | The session-target community. ECA configures access for community member profile `Advanced RX Customer Community Plus Login User`.                                                                                     |
| Existing refill wizard                                                                                        | Built on `Account_Sig__c` transient state. The magic link's `retPath` lands here. **Phase 3 verification confirms wizard initialization is clean from a magic-link-established session** (carryover from V1 Risk #10). |
| `Auth.JWT`, `Auth.JWS`, `Auth.JWTBearerTokenExchange` Apex                                                    | Not used in V2 — the JWT minting moves entirely to Azure. Listed for completeness so a future reader doesn't expect them.                                                                                              |

### §4.3 Why a separate Function App, not bolted onto eFax

| eFax Function App                                      | V2 Magic-link Function App                                         |
| ------------------------------------------------------ | ------------------------------------------------------------------ |
| Inbound vendor webhooks (machine-to-machine)           | Public-internet patient traffic (browser-facing)                   |
| Known vendor IP set; firewall-allowlistable            | Patients on arbitrary mobile carriers + home Wi-Fi                 |
| Authentication via HMAC + IP allowlist                 | Authentication via short-code-as-bearer; rate-limited; WAF-fronted |
| Bearer credentials never in URLs                       | Bearer credential IS the URL path                                  |
| Failure mode: fax processing stalls                    | Failure mode: patients can't log in                                |
| Threat actors: virtually nobody — back-office endpoint | Threat actors: anyone who guesses the URL pattern                  |

Sharing the Function App means a bug or scaling issue in one path can take down the other. The infrastructure (VNet, Key Vault, SQL, monitoring, deploy pipeline) reuses cleanly; the compute boundary does not. Codex confirmed this trade in its audit; we accept it.

### §4.4 Existing artifacts the lead dev should read first

In order, before starting V2:

1. `CLAUDE.md` — full org operating context.
2. `.claude/rules/apex.md` — Apex conventions for the thin Salesforce-side surface V2 still needs.
3. `business-context/storefront-data-audit.md` — full B2B storefront data map. Required reading per CLAUDE.md §3 for any storefront-touching code.
4. `business-context/integrations/sfmc.md` — SFMC surface area + active credential-leak hazard (carryover from V1 §4.3).
5. `business-context/workflows/patient-texting-refill-reminders.md` — the existing refill-reminder send pipeline this project hooks into. §4.1 (channel gate predicates) is load-bearing.
6. `business-context/objects/Account_Sig.md` — refill-wizard transient state; informs Phase 4 wizard-initialization verification.
7. Salesforce-only alternative: [`../magic-link-portal-login.md`](../magic-link-portal-login.md) — referenced when the §5 spikes are run.
8. AdvancedRx Azure repo (`advancedrx-document-integration`): the eFax Function App + e-script + patient-matcher are the operational pattern references for the V2 Function App.

### §4.5 AdvancedRx SFMC infrastructure — facts captured during Spike B

Observed in delivered email headers from the 2026-05-21 production self-send test. Useful baseline for any future SFMC-touching work:

| Element                        | Value                                                                                                    |
| ------------------------------ | -------------------------------------------------------------------------------------------------------- |
| **Sending stack**              | 11 (`s11.y.mc.salesforce.com`)                                                                           |
| **From domain**                | `hello@advancedrx.email` (note `.email`, NOT `.net` or `.co`)                                            |
| **Vanity click-tracking host** | `click.advancedrx.email` — replaces SFMC's default `cl.exct.net` redirect domain                         |
| **Bounce domain**              | `bounce.advancedrx.email` (envelope-from `bounce-*.110054@bounce.advancedrx.email`)                      |
| **DKIM signing domains**       | `advancedrx.email` (selector `11dkim1`) + `s11.y.mc.salesforce.com` (selector `fbldkim11`) — dual-signed |
| **DMARC posture**              | p=REJECT, sp=REJECT, alignment passes                                                                    |
| **Subscription Center host**   | `click.advancedrx.email/subscription_center.aspx?jwt=...` (List-Help header)                             |

**Implications for V2:**

- Magic-link URL host (`login.advancedrx.net`) is a **different domain** from the SFMC sending domain (`advancedrx.email`). That's fine deliverability-wise — `login.advancedrx.net` doesn't need to align with SFMC's sending-domain SPF/DKIM/DMARC; only the SFMC click-tracking rewrites would. If SFMC wraps `login.advancedrx.net/r/...` into `click.advancedrx.email/...`, the user-visible URL changes but the destination resolves the same.
- The vanity click-tracking host means real-send (non-test) URL wrapping, if it occurs, produces `click.advancedrx.email/...` redirects rather than ExactTarget's default `cl.exct.net/...` — slightly cleaner brand presentation but operationally identical.
- The §5 prereq email-channel deliverability review (`login.advancedrx.net` SPF/DKIM/DMARC alignment) is essentially moot: it's a destination URL, not a sender domain, so it doesn't need DKIM alignment with SFMC.

---

## §5 Prerequisites — must be true before build starts

1. **Gating spikes (run in parallel, ~half-day each):**
   - **Spike A — Salesforce CCP Login + singleaccess proof. ✓ PASSED 2026-05-18 in `ClaudeTest`.** Full handshake confirmed working end-to-end for the Customer Community Plus Login license against the Experience Cloud token + singleaccess endpoints. Three implementation details surfaced during the spike (folded into §6 Phase 2): (a) portal-account-owner-needs-a-role gotcha when provisioning community users, (b) ConnectedApp pre-authorization requires the SetupEntityAccess path — `applicationVisibilities` on Profile/PermSet is for Custom Apps only, not Connected Apps, (c) Spring '26 ConnectedApp UI-creation restriction does NOT appear to block metadata-API deploys in this org. Spike artifacts cleaned up except for a deactivated portal user + linked Contact ("MAGICLINK SPIKE") + Account ("SPIKE") that can't be deleted because Salesforce treats portal-user Contact links as immutable — they're inert and named for recognition.
   - **Spike B — SFMC link behavior. ✓ SOFT-PASSED 2026-05-21 via production SFMC self-send.** Test-send delivered both fragment (`/r#TEST123FRAGMENT`) and path (`/r/TEST123PATH`) URLs to Gmail with the `href` values completely unmodified — no wrapping through `click.advancedrx.email`, no URL rewriting at all. **Caveat:** the test used SFMC's Test Send feature with "Suppress this send from reports" checked. SFMC documentation and well-known QA gotchas indicate Test Sends often skip click-tracking link wrapping even when "Track Clicks" is on — so this is positive evidence the SMTP delivery layer doesn't mutate fragments, but it does NOT definitively prove production journey/triggered-send wrapping behavior. **Final verification deferred to Phase 6a A/B rollout** with one real send (see §6 Phase 6a). V2 design accommodates both outcomes: primary = fragment-based code; fallback = path-based with HMAC hashing per §7.

2. **SFMC credential rotation complete** (carryover from V1 §5 prereq #1). Building Azure-mediated auth infrastructure that runs alongside SFMC integration surfaces with leaked credentials is bad sequencing. Tracked in `MARISSA-HANDOFF.md` §2 #2.
3. **Test SFMC sandbox available and reachable from Azure** (carryover from V1).
4. **Email-channel deliverability review.** Confirm `login.advancedrx.net` (new subdomain) doesn't trip the org's SPF/DKIM/DMARC posture for SFMC-sent email. The href domain needs to align with SFMC sending-domain config or it'll get flagged as phishing.
5. **Acceptance of link-compromise risk per channel** (carryover from V1 §5 prereq #4). SMS: SIM swap, shared family phone, screenshot forwarding. Email: shared inbox, forwarding rules, archived mailbox dumps, links sitting in mailboxes for years. Mitigation: 24-hour TTL, kill-on-completion, admin revocation. Risk acceptance documented per channel by Kyle as compliance counsel.
6. **Contact identifier contract for the SFMC mint call.** SFMC's refill-reminder data extension must carry the Salesforce 18-char `ContactId` for each recipient so it can be passed to Azure's mint endpoint. If the data extension doesn't already have it, the contract is: SFMC populates `ContactId` from the underlying Contact via the same plumbing that drives other personalization. Confirmed approach: use Salesforce 18-char Contact Id as the patient identifier (not a new mapping UUID — Azure's contact cache already holds Contact data, so there's no privacy reason to indirect).

---

## §6 Phased implementation

Each phase ends with a deployable, testable increment. Do not start phase N+1 until phase N is verified.

### Phase 0 — Pre-work + gating spikes

- **Spike A — ✓ PASSED 2026-05-18 in `ClaudeTest`.** JWT Bearer Flow + `/services/oauth2/token` + `/services/oauth2/singleaccess` confirmed working end-to-end for a Customer Community Plus Login license user. Verified: token endpoint returns access_token + sfdc_community_url + user URL; singleaccess returns frontdoor_uri JSON; following the frontdoor_uri sets the `sid` session cookie + returns HTTP 200 on the storefront page. V2 architecture green-lit on the singleaccess question.
- **Spike B — ✓ SOFT-PASSED 2026-05-21 via production SFMC self-send.** Both fragment URL (`/r#TEST123FRAGMENT`) and path URL (`/r/TEST123PATH`) delivered to Gmail with the `href` values completely unmodified — no wrapping through `click.advancedrx.email`, no URL rewriting. SFMC sending infrastructure captured in §4.5. **Caveat:** test used SFMC Test Send with "Suppress this send from reports" checked; Test Sends often skip click-tracking link wrapping. Real journey/triggered-send wrapping behavior deferred to Phase 6a A/B rollout. V2 design accommodates both outcomes.
- (UX flow already decided — one-click, no interstitial. See §3.6.)
- Generate signing certificate (RSA 4096-bit) via Azure Key Vault. Upload public cert to Salesforce External Client App.
- Lock cert rotation calendar (TLS cert lifespan drops to 200 days in March 2026; plan for ≥96h rotation overlap window per §8.4 — link TTL 72h + 24h rollback buffer).
- Confirm Entra app registration `MagicLink-API` for SFMC→Azure auth, OR confirm HMAC fallback secret in Key Vault. (Codex's pre-built guidance: prefer Entra OAuth client credentials.)
- Choose magic-link hostname. **Recommendation: `login.advancedrx.net`** — separate subdomain from the storefront, doesn't drag the storefront into the Front Door WAF posture decision, fits comfortably in an SMS.

**Spike A — gotchas surfaced during execution (recorded for the build team):**

| #   | Symptom                                                                                            | Cause                                                                                                                                                                                                                            | Fix during build                                                                                                                                                                                                                                                                                                                                                                                                     |
| --- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | User creation errored: `portal account owner must have a role`                                     | The parent Account's owner had no `UserRoleId`. AdvancedRx has an afterSave flow on Account that reassigns ownership to a `salesforceadmin` user without a role.                                                                 | Before provisioning a community user, ensure the parent Account's `OwnerId` is a user with a `UserRoleId`. If the afterSave flow re-reassigns, the User insert must run before the AfterSave fires or the OwnerId must be re-set immediately before the User insert.                                                                                                                                                 |
| 2   | JWT Bearer succeeded but token endpoint returned `invalid_app_access — user is not admin approved` | ConnectedApp `<isAdminApproved>true</isAdminApproved>` requires explicit pre-authorization per user; the standard `applicationVisibilities` element on Profile/PermSet metadata is for **Custom Apps only**, NOT Connected Apps. | Grant pre-auth via SetupEntityAccess: (1) deploy a PermissionSet via metadata, (2) insert a `SetupEntityAccess` record linking `ParentId=permsetId, SetupEntityId=connectedAppId` — only writable via Apex or Tooling API, NOT standard CRUD, (3) assign the permset to the target community user(s). For the production build this means a small post-deploy Apex script that wires SetupEntityAccess once per env. |
| 3   | After fix #2 with `isAdminApproved=false`: `user hasn't approved this consumer`                    | "All users may self-authorize" requires the user to interactively approve once via UI consent — community users can't.                                                                                                           | Use `<isAdminApproved>true</isAdminApproved>` combined with the SetupEntityAccess pre-auth path above.                                                                                                                                                                                                                                                                                                               |
| 4   | Spring '26 messaging suggests new ConnectedApp creation is blocked by default                      | Metadata-API deploys for ConnectedApp **worked** in this org despite the messaging.                                                                                                                                              | Not a blocker. V2 still uses ECA per architecture direction; ConnectedApp deploy works as a fallback if ever needed.                                                                                                                                                                                                                                                                                                 |

**Spike B — gotchas surfaced during execution (recorded for the SFMC + build teams):**

| #   | Symptom                                                                                                                                                          | Cause                                                                                                                                                                            | Fix during build                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Test Send failed: `Emails containing subscriber specific AMPscript cannot be sent without selecting a subscriber`                                                | A comment-style marker `%%[ /* TRACKING_TEST = ON */ ]%%` in the HTML body was parsed by SFMC as AMPscript syntax — Test Sends require subscriber context to evaluate AMPscript. | Never put bare `%%[ ... ]%%` blocks in template HTML for QA-by-Test-Send. Real journey/triggered sends carry subscriber context so AMPscript IS valid in production templates — but Test-Send-mode QA will fail until a subscriber is bound.                                                                                                                                                                                                                                                                                                                            |
| 2   | Test Send failed: `email is missing a valid physical mailing address` per CAN-SPAM                                                                               | "Default Commercial" send classification enforces the federal CAN-SPAM physical-address requirement at the SFMC platform level.                                                  | Two options: (a) add the standard Member\_\* substitutions to the bottom of the HTML body — they auto-populate from SFMC account-level settings (Setup → Account → Information): `%%Member_Busname%% / %%Member_Addr%% / %%Member_City%% / %%Member_State%% / %%Member_PostalCode%% / %%Member_Country%%`. SFMC's CAN-SPAM checker recognizes this pattern as satisfying the requirement. (b) Switch to a Transactional send classification — magic-link auth IS transactional, so this is the right call for production sends (CAN-SPAM exempts transactional emails). |
| 3   | Test Send with "Track Clicks" ON + "Suppress this send from reports" checked delivered URLs completely unmodified — no wrapping through `click.advancedrx.email` | SFMC Test Sends commonly skip click-tracking link wrapping even when Track Clicks is enabled in the dialog. Real journey/triggered sends may behave differently.                 | Don't draw conclusions about production wrapping behavior from Test Send data. Phase 6a A/B rollout includes one real send to Kyle as a recipient to definitively confirm. V2 design accommodates either outcome (fragment-based primary + path-based fallback).                                                                                                                                                                                                                                                                                                        |
| 4   | Magic-link URL host (`login.advancedrx.net`) doesn't match SFMC From domain (`hello@advancedrx.email`)                                                           | AdvancedRx uses a separate, dedicated email-sending domain for SFMC (`.email`) distinct from the corporate marketing domain (`.net`) and the magic-link-specific subdomain.      | Not a problem. The href domain doesn't need DKIM/SPF alignment with the From domain — only the From domain needs alignment, which `advancedrx.email` already has (dual-DKIM signed, DMARC p=REJECT pass). The §5 prereq SPF/DKIM alignment review for `login.advancedrx.net` is essentially moot.                                                                                                                                                                                                                                                                       |

### Phase 1 — Azure infrastructure foundation

- Create Function App `fn-magiclink-prod-east2` (.NET 8 isolated, EP1 with `minimumElasticInstanceCount = 1`, VNet-integrated, system-assigned Managed Identity).
- Provision SQL tables in `advancedrx_integration`:
  - `dbo.MagicLinkLogins` (state) — see §7.2
  - `dbo.MagicLinkLoginEvents` (audit) — see §7.2
  - `sfdc.SalesforcePortalUserCache` (new sibling to contact cache) — see §7.5
- All writes go through `[integration_api].usp_*` stored procs per repo convention.
- Provision Front Door Standard with `login.advancedrx.net` custom domain, managed TLS, custom WAF rules (route allowlist, method allowlist, per-IP rate limits, size limits, geo policy), origin restricted to `AzureFrontDoor.Backend` + `X-Azure-FDID` header.
- App Insights instance with telemetry scrubbing rules (see §8.5) applied at function-app level.

### Phase 2 — Salesforce contact-cache extension

- Extend the existing ADF nightly job (currently mirrors `Contact` to `sfdc.SalesforceContactCache`) to also mirror `User WHERE ContactId IS NOT NULL AND IsActive = true` into the new `sfdc.SalesforcePortalUserCache`.
- Link the two by `ContactId` so Azure can resolve `ContactId → Username` in a single query.
- **Verify integration-user permissions before Phase 2 build.** The integration user that currently powers the Contact-sync ADF job needs read access to the `User` standard object. If the integration user lives on the canonical `Minimum Access - <Integration Name>` profile pattern (per CLAUDE.md §4), it almost certainly does NOT have User-read by default. Resolution: deploy a small permission set named `Magic_Link_User_Cache_Read` granting "Read" on `User` (limited fields: `Id, ContactId, Username, IsActive, Profile.Name`), assigned to the integration user in the same change. This is the second (and only other) Salesforce-side artifact for this project.
- Validate cache freshness: nightly ADF run completes; counts match Salesforce.
- **Edge case to flag with refill-reminder team:** patient who places their first order today has a portal User provisioned same-day but won't appear in the Azure cache until tomorrow's ADF run. If the refill-reminder cadence ever drops below 24h after first order, the cache misses. Almost certainly not in scope (reminders fire on a refill cadence days/weeks later), but worth a 30-second sanity check.

### Phase 3 — Function App endpoints

Build the Functions per §7.1. Each one has at least one outcome-asserting test:

- `POST /api/magic-links` (mint)
- `GET /r/{shortCode}` (one-click bridge — full lookup + JWT exchange + 303 redirect)
- `GET /healthz`

Deferred to post-launch (only if operational need surfaces): `POST /api/magic-links/{magicLinkId}/revoke` + bulk variant for the optional Salesforce-side admin tool.

JWT assembly uses `Microsoft.IdentityModel.JsonWebTokens` + `Microsoft.IdentityModel.Tokens`; signing via `Azure.Security.KeyVault.Keys` `CryptographyClient`. No hand-rolled JWT construction.

### Phase 4 — Salesforce-side surface (small)

- Deploy the **External Client App** `Magic_Link_Bridge_ECA` with the public cert from Phase 0. Settings per §7.6.
- **Deploy a Permission Set `Magic_Link_Bridge_Access`** scoped to the Customer Community Plus Login license. Empty (no field/object visibilities); exists only to be the target of a `SetupEntityAccess` grant.
- **Wire pre-authorization via SetupEntityAccess.** Run a one-time post-deploy Apex script (Phase 4 deploy artifact) that inserts a `SetupEntityAccess` record with `ParentId = Magic_Link_Bridge_Access permset Id, SetupEntityId = Magic_Link_Bridge_ECA Id`. This is the mechanism that pre-authorizes community users for the ECA — discovered during Spike A; see §6 Phase 0 "Spike A gotchas" row 2 for the full reasoning. Note: SetupEntityAccess is only writable via Apex or Tooling API, NOT standard CRUD / data loader.
- **Assign `Magic_Link_Bridge_Access` to all portal community users** that should be eligible for magic-link login. Provisioning hook: extend the existing portal-user-provisioning path (whatever creates community Users post-first-order) to also insert the PermissionSetAssignment.
- Combined with the `Magic_Link_User_Cache_Read` permission set from Phase 2 (if it was needed there) and the `Magic_Link_Bridge_Access` permset above, this is the entire Salesforce-side surface for the project. No Apex business logic, no Named Credentials, no flows, no community pages, no custom objects — just three metadata records + one auto-assigned permset on community users + a one-time post-deploy script.
- **Phase 4 verification — refill-wizard landing UX.** Click magic link as a test patient (admin-issued for test). Confirm the refill wizard loads at the expected starting screen with no stale `Account_Sig__c` values affecting the experience (carryover from V1 Risk #10). If wizard state initialization is needed for the magic-link entry path, scope as a Phase 4.5 task with explicit boundaries — do not silently expand this project to refactor the wizard.

**Deferred to post-launch (optional):** an admin-revoke tool (`MagicLinkAdminController` + a Named Credential pointing at Azure's `/revoke` endpoint) for cases like "patient called in saying they lost their phone — please kill any live links." Not required for v1 because the 72h time-based expiry handles the vast majority of kill cases. If operational need surfaces post-launch, add as a small follow-on PR.

### Phase 5 — Production hardening + observability

- App Insights workbook + alerts modeled on the eFax observability stack: mint failures, redeem failures, Salesforce dependency failures, invalid-code spikes, throttling spikes, SQL/KV dependency failures, no successful redeems during expected SFMC send windows.
- Synthetic monitoring: hourly canary contact in the cache + a Function-internal mint/redeem cycle that exercises the full path without creating a real refill. Page on canary failure.
- Confirm cert rotation runbook executes end-to-end on a non-prod cert pair (rotate, smoke-test, roll back, re-rotate) so the operational procedure is exercised before the first real rotation is needed.

### Phase 6a — SMS template wiring + go-live

- AMPScript snippet added to the SMS template invoked by `JBSystemFlow_Refill_Reminder_c`'s SMS callsite. Snippet calls `HTTPPost2` against `https://mint.advancedrx.net/api/magic-links` with auth per Phase 0, receives shortCode, renders `https://login.advancedrx.net/r/{shortCode}` in the message.
- **Confirm send classification = Transactional** (magic-link auth IS transactional under CAN-SPAM definitions; classification choice affects link wrapping behavior + skips the physical-address requirement). Captured in §6 Phase 0 Spike B gotcha #2.
- **Real-send fragment verification (closes V2 Risk #2):** before A/B widening, send ONE real (non-test) send to Kyle as the sole recipient via a single-row data extension. Inspect the delivered `.eml` to confirm whether real journey-send mode wraps the URL through `click.advancedrx.email` or preserves the original. If wrapping occurs and strips the fragment → flip URL shape from fragment-based (`/r#{code}`) to path-based (`/r/{code}`) per the §7 fallback design before continuing rollout. If wrapping doesn't occur (or preserves the fragment) → continue with the primary fragment design.
- A/B rollout: 10% of refill-reminder SMS sends → 50% → 100% over 2 weeks, watching `MagicLinkLoginEvents` for failure rate.

### Phase 6b — Email template wiring + go-live

- Same AMPScript pattern added to the OMM email template invoked by `JBSystemFlow_Refill_Reminder_c`'s email callsite. Email renders a "View Your Prescription" button with the magic link as href (URL not visually displayed — display text is the button label).
- Confirm `GetJWTByKeyName` is NOT in use (V2 doesn't mint JWTs in SFMC). Only `HTTPPost2` + `JSONParseValue` + standard AMPScript are required — these are universally available in OMM email surfaces, so V1's §5b prereq verification dissolves.
- Same A/B rollout pattern as 6a, on the email cohort.

---

## §7 Components to build — technical detail

### §7.1 Azure Function endpoints

**`POST /api/magic-links` — mint**

Authenticated via Entra OAuth client credentials (issuer + audience + signature + expiry + allowed-client-id check; not the weak "decode audience only" pattern).

```
Request:
{
    "contactId": "0034W00002XXXXX",   // Salesforce 18-char Contact Id
    "channel": "SMS",                  // "SMS" | "Email"
    "retPath": "/refill",              // allowlisted relative path; validated against App Config list
    "idempotencyKey": "<journey-msg-uuid>"
}

Response:
{
    "magicLinkId": "<uuid>",
    "shortCode": "Kj3pQ9xMv7nR",       // 12 chars base62 (~71 bits entropy)
    "shortUrl": "https://login.advancedrx.net/r/Kj3pQ9xMv7nR",
    "expiresAtUtc": "2026-05-19T14:00:00Z"
}
```

Behavior:

1. Validate body schema; reject oversize/missing/wrong-type.
2. Validate `retPath` against allowlist (App Config setting; v1 ships with `/refill` only).
3. Check `idempotencyKey` — if seen recently for the same `contactId`, return the existing `shortUrl` (SFMC retry safety).
4. Generate 128-bit-randomness short code, base62 encode to 12 chars. Retry on collision (max 3 retries).
5. `shortCodeHash = HMAC-SHA256(activePepper, normalizedCode)`. Pepper lives in Key Vault with key version on the row.
6. Resolve `contactId` → cached `UserId` via `SalesforcePortalUserCache`. If unresolved, log + return 422 (this means the patient doesn't have a portal user yet — should never happen since reminders fire after first order, but defend against it).
7. Insert row in `MagicLinkLogins` via `[integration_api].usp_CreateMagicLink`.
8. Append `Token_Minted` event in `MagicLinkLoginEvents`.
9. Return response. Never include the plaintext code in App Insights telemetry.

**`GET /r/{shortCode}` — one-click bridge**

Runs the full login flow synchronously and responds with a 303 redirect. Sarah's browser follows it and lands authenticated. No interstitial; no second click. Rationale + trade documented in §3.6.

Response headers:

- On success: `HTTP 303 See Other`, `Location: {frontdoor_uri}`, `Cache-Control: no-store, private`, `Referrer-Policy: no-referrer`
- On rejection (expired / revoked / completed / unknown): minimal HTML page with generic "This link is no longer valid — please request a new one" + a phone/support fallback. `Cache-Control: no-store`. No PHI, no diagnostic detail.

Behavior:

1. Hash `shortCode` via HMAC-SHA256 with peppered key in Key Vault.
2. Call `[integration_api].usp_ResolveMagicLinkForRedeem` — atomically returns the row IF expired / revoked / completed checks pass.
3. On rejection: append the appropriate `Bridge_Failure_*` event; return the generic-error HTML page.
4. On success: resolve cached Salesforce Username for the row's `UserId` from `SalesforcePortalUserCache`.
5. Build JWT Bearer assertion (claims: `iss`=ECA client_id, `sub`=Username, `aud`=`https://login.salesforce.com` per spec — community-user `aud` may differ; will be confirmed in Spike A — `exp`=now+180s, `jti`=uuid).
6. Sign via Key Vault — `CryptographyClient.SignAsync` against the assertion's `header.payload`. Private key never leaves Key Vault.
7. POST to Salesforce `/services/oauth2/token`. Short timeout (5s); one retry on transient 5xx/429; no retry on 4xx auth errors.
8. Receive access_token; POST it to `/services/oauth2/singleaccess`.
9. Receive `frontdoor_uri`.
10. Update `MagicLinkLogins.LastRedeemAtUtc` and increment `RedeemCount` (multi-use semantic — does not consume the row).
11. Append `Bridge_Success` event with operation-id correlation and scanner-classification flag (see §8.5 + §10 Risk #17).
12. Respond `303 See Other → frontdoor_uri`.

Never log: JWT contents, access tokens, `frontdoor_uri`, raw codes, code hashes, usernames, contact identifiers, IP-or-UA in plaintext (hashed only if needed for correlation).

**Why scanner-induced sessions are acceptable here** (cross-reference §3.6):

Microsoft Safe Links, Gmail link checking, corporate gateways will GET this endpoint as part of their inspection. Each such hit consumes one Salesforce token-exchange round-trip and one singleaccess round-trip. The resulting `frontdoor_uri` lives only inside the scanner's HTTP client memory and self-destructs when the scan job ends. Sarah's link remains valid (multi-use within 24h) and her real tap works normally. The audit-log noise and Salesforce API overhead from scanner traffic are mitigated by:

- Front Door WAF rules classifying known scanner sources where possible (User-Agent + IP signatures); flagged events carry a `ScannerSuspected = true` attribute in `MagicLinkLoginEvents`
- Triage dashboards default to `ScannerSuspected = false` so human-vs-scanner traffic separates cleanly
- Salesforce API volume estimate + headroom check tracked as §10 Risk #17

**`GET /healthz` — Front Door health probe.** Returns 200 if SQL + Key Vault are reachable; 503 otherwise. No PHI, no auth required.

**Deferred (post-launch, only if operational need surfaces):** `POST /api/magic-links/{magicLinkId}/revoke` + bulk-by-user variant for the optional Salesforce-side admin-revoke tool. Same auth contract as the mint endpoint (Entra OAuth or HMAC); sets `RevokedAtUtc` on the row; subsequent GET attempts return the generic "this link is no longer valid" page. Not required for v1 because the 72h time-based expiry handles the vast majority of kill cases.

### §7.2 SQL tables

**`dbo.MagicLinkLogins`** — state

| Column                | Type                   | Notes                                                                                 |
| --------------------- | ---------------------- | ------------------------------------------------------------------------------------- |
| `MagicLinkId`         | uniqueidentifier PK    | Internal id; exposed in admin/completion contracts                                    |
| `ShortCodeHash`       | binary(32) UNIQUE      | HMAC-SHA256(activePepper, normalizedCode)                                             |
| `HashPepperVersion`   | int                    | Key Vault key version used; survives pepper rotation                                  |
| `ContactId`           | varchar(18)            | Salesforce 18-char Contact Id (matched against `SalesforcePortalUserCache.ContactId`) |
| `UserId`              | varchar(18)            | Cached Salesforce User Id at mint time (denormalized for stability if cache changes)  |
| `Channel`             | varchar(16)            | `SMS` or `Email`                                                                      |
| `RetPath`             | varchar(255)           | Allowlisted relative path; e.g. `/refill`                                             |
| `IdempotencyKey`      | varchar(128)           | For SFMC retry safety                                                                 |
| `CreatedAtUtc`        | datetime2              | Mint time                                                                             |
| `ExpiresAtUtc`        | datetime2 INDEXED      | TTL — default mint-time + 72h                                                         |
| `RevokedAtUtc`        | datetime2 NULLABLE     | Set by `/revoke` (post-launch optional endpoint)                                      |
| `LastRedeemAtUtc`     | datetime2 NULLABLE     | Updated on each successful bridge (multi-use until revocation/expiry)                 |
| `RedeemCount`         | int DEFAULT 0          | Cumulative successful bridges                                                         |
| `LastFailureCategory` | varchar(64) NULLABLE   | Last failure code for triage                                                          |
| `CampaignMetadata`    | nvarchar(512) NULLABLE | Journey/message id for analytics; non-PHI                                             |

Indexes: `ShortCodeHash` (unique), `ExpiresAtUtc` (for purge), `ContactId` (for revoke-all-active lookups).

**`dbo.MagicLinkLoginEvents`** — append-only audit log

| Column              | Type                     | Notes                                                                                                                                                                                                |
| ------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EventId`           | bigint IDENTITY PK       |                                                                                                                                                                                                      |
| `MagicLinkId`       | uniqueidentifier INDEXED | FK semantics (no enforced FK to allow event survival after row purge)                                                                                                                                |
| `EventType`         | varchar(40)              | `Token_Minted`, `Bridge_Success`, `Bridge_Failure_Expired`, `Bridge_Failure_Revoked`, `Bridge_Failure_Token_Exchange`, `Bridge_Failure_SingleAccess`, `Link_Revoked`, `Invalid_Attempt`, `Throttled` |
| `ScannerSuspected`  | bit                      | True if WAF/UA signatures flagged the source as a likely link scanner (Microsoft Safe Links, Gmail link checking, corporate gateway). Triage dashboards default-filter this to false.                |
| `EventTimestampUtc` | datetime2 INDEXED        |                                                                                                                                                                                                      |
| `ResultCategory`    | varchar(64) NULLABLE     | Bucketed failure reason                                                                                                                                                                              |
| `SourceIpHash`      | binary(32) NULLABLE      | HMAC-hashed IP (telemetry pepper)                                                                                                                                                                    |
| `UserAgentHash`     | binary(32) NULLABLE      | HMAC-hashed UA                                                                                                                                                                                       |
| `OperationId`       | varchar(64) NULLABLE     | App Insights correlation id                                                                                                                                                                          |
| `Detail`            | nvarchar(512) NULLABLE   | Bounded; non-PHI; non-bearer-content                                                                                                                                                                 |

Retention: indefinite (HIPAA 6-year minimum easily covered). Purge `MagicLinkLogins` rows where `ExpiresAtUtc < now - 7 days`; **keep audit events forever** (they're small and the patient-facing breach-investigation requirement is real).

### §7.3 Short-code generation + hash strategy

- Source of randomness: `RandomNumberGenerator.GetBytes(16)` (128 bits of cryptographic randomness).
- Encoding: base62, truncated to 12 chars (~71 bits surface entropy in the visible code; the 128 bits of randomness behind the encoding is preserved).
- Storage: only the HMAC-SHA256 hash, never the plaintext. Lookup is a single indexed hash compare.
- Pepper: lives in Key Vault, retrievable only by Function MI. Versioned so rotation doesn't invalidate existing live links — rows carry `HashPepperVersion`.
- Brute-force defense: rate limiting at Front Door + WAF; 12-char base62 surface gives ~3.2 × 10^21 combinations; combined with per-IP rate limits and one-time-use semantics on each row, brute force is impractical.

### §7.4 Front Door Standard configuration

- Custom domain: `login.advancedrx.net` with managed TLS, HSTS enabled.
- WAF: Standard tier (custom rules; Premium-only Bot Manager and managed rule sets deferred to platform-level upgrade later if/when more public Azure ingress justifies the cost).
- Custom WAF rules:
  - Method allowlist: GET, POST, OPTIONS only
  - Route allowlist: `/r/*`, `/api/magic-links*`, `/healthz` — block everything else at the edge
  - URI/body/header size limits (block oversized requests)
  - Per-IP rate limit on `/r/*` (e.g., 30/min)
  - Per-IP rate limit on `/api/magic-links*` (lower; e.g., 10/min)
  - Geo policy if desired (deferred — patients can be anywhere)
- Origin: lock down to Front Door via `X-Azure-FDID` header check at the function-app level + Service Tag `AzureFrontDoor.Backend` IP allowlist.
- Caching: disabled for all magic-link routes.

### §7.5 `sfdc.SalesforcePortalUserCache` — schema + ADF pipeline

Extends the existing `sfdc.SalesforceContactCache` pattern:

| Column            | Type                | Notes                                                                     |
| ----------------- | ------------------- | ------------------------------------------------------------------------- |
| `UserId`          | varchar(18) PK      |                                                                           |
| `ContactId`       | varchar(18) INDEXED | Used by V2 to resolve mint requests                                       |
| `Username`        | varchar(80)         | The value put in JWT `sub`                                                |
| `IsActive`        | bit                 | `User.IsActive`                                                           |
| `ProfileName`     | varchar(120)        | Used for audit; expected `Advanced RX Customer Community Plus Login User` |
| `LastSyncedAtUtc` | datetime2           | ADF nightly run timestamp                                                 |

ADF pipeline: extend the existing nightly Contact-sync pipeline with a sibling job that queries `SELECT Id, ContactId, Username, IsActive, Profile.Name FROM User WHERE ContactId != null AND IsActive = true` and upserts into the cache table.

### §7.6 Salesforce External Client App configuration

| Setting                  | Value                                                                                                                                                                                 |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API name                 | `Magic_Link_Bridge_ECA`                                                                                                                                                               |
| OAuth enabled            | Yes                                                                                                                                                                                   |
| Callback URL             | `https://login.salesforce.com/services/oauth2/success` (required field; not used in JWT Bearer flow)                                                                                  |
| Use digital signatures   | Yes; upload public cert from Azure Key Vault (Phase 0)                                                                                                                                |
| OAuth scopes             | `api`, `web`, `refresh_token offline_access`, `openid` — all four required (`web` enables session bridging; `refresh_token` required despite JWT Bearer not returning refresh tokens) |
| Permitted users          | "Admin approved users are pre-authorized"                                                                                                                                             |
| Profile / permission set | Assign `Advanced RX Customer Community Plus Login User`                                                                                                                               |
| IP relaxation            | "Relax IP restrictions" (clicks come from arbitrary patient IPs; the actual ingress hardening is at Azure Front Door)                                                                 |
| Refresh token policy     | "Immediately expire refresh token" (we don't use refresh tokens)                                                                                                                      |

### §7.7 Salesforce-side Apex

**None for v1.** The entire Salesforce-side surface is the External Client App record (§7.6). Time-based expiry handles link kill; the 72h TTL plus multi-use semantics inside the window mean no completion webhook is needed.

**Deferred (post-launch, only if operational need surfaces):** `MagicLinkAdminController` Apex wrapper for the optional Azure `/revoke` endpoint. Would be a small `@AuraEnabled` invocable behind an admin LWC, follow `.claude/rules/apex.md` conventions (`with sharing`, NC for callout, `AuraHandledException`, outcome-asserting tests). Add only if support teams ask for it.

### §7.8 Named Credentials (Salesforce → Azure)

**None for v1.** Salesforce makes no outbound callouts to Azure in v1 — the JWT exchange is Azure → Salesforce (inbound to Salesforce, no Salesforce-side Named Credential needed).

**Deferred (post-launch, only if the admin-revoke tool ships):** a single Named Credential `Azure_MagicLink_Admin` pointing at `https://mint.advancedrx.net` with Entra OAuth or HMAC auth per Phase 0 decision. Modern-style NamedCredentialParameter for URL; auth via External Credential. **Do not paste the Azure URL or any secret into Apex** when the admin tool ships — reference `callout:Azure_MagicLink_Admin`.

---

## §8 Security and HIPAA controls

### §8.1 Token + code security

- Short code: 128 bits of randomness, base62-encoded to 12 chars, HMAC-SHA256 hashed before SQL storage. Pepper in Key Vault.
- Link lifetime: **72 hours**, configurable per channel via App Config if needed (single TTL for v1).
- Salesforce JWT Bearer assertion: 180-second lifetime, generated at click time, single-use semantically.
- Kill conditions: multi-use within the 72h window until `ExpiresAtUtc` lapses OR (post-launch optional) `RevokedAtUtc` set via admin endpoint. No completion-based kill — see §3.6 + §1 for the rationale.
- ECA private key: never leaves Key Vault. Signed in-place via `CryptographyClient.SignAsync`. MI granted "Key Vault Crypto User" scoped to the signing key only.

### §8.2 Audit retention

- `MagicLinkLoginEvents` in Azure SQL is the authoritative audit log. Indefinite retention.
- Salesforce-side audit is intentionally minimal — `Error_Log__c` only on Salesforce-side callout failures from `MagicLinkCompletionNotifier`.
- App Insights carries scrubbed operational telemetry only (latency, status, dependency results); does NOT carry the audit record.

### §8.3 Rate limiting

- Front Door per-IP rate limits as the primary control (see §7.4).
- Per-user issuance limit enforced in `usp_CreateMagicLink`: max 5 active unconsumed links per user; new mint invalidates the oldest active link. Defends against mint-spam.
- No per-IP rate limiting in Function App SQL — that's already done at the edge.

### §8.4 Cert rotation

**96-hour minimum overlap window** (link TTL 72h + 24h rollback buffer); 120h preferred for safety. Runbook:

1. Add new public cert to Salesforce ECA alongside the old cert.
2. Deploy new Key Vault key/cert version.
3. Flip App Config setting to the new key version. Smoke test mint/redeem.
4. Monitor for 24h.
5. Keep old cert trusted for **at least 96 hours** (link TTL 72h + 24h rollback buffer) so in-flight links signed during cutover still validate. 120h preferred.
6. Remove old public cert from Salesforce ECA; disable/archive old Key Vault key version.

Annual cert rotation cadence; calendar reminders set at Phase 0.

### §8.5 Telemetry scrubbing rules (explicit)

App Insights `ITelemetryProcessor` filters:

- **Drop entirely:** request bodies; response bodies; `Authorization` headers; `Cookie` headers; JWT assertions; Salesforce access tokens; `frontdoor_uri` values; raw short codes; code hashes; usernames; contact/user/account identifiers in cleartext; patient name / DOB / email / phone (these never appear in V2's payload, but defense in depth); full URLs for `/r/*` (route template only).
- **Hash with telemetry pepper:** client IP; user agent; contact/user identifiers if needed for correlation.
- **Allow through:** `magicLinkId`, operation id, result category, channel, route template, dependency host + path category (not full path), status code, duration, Salesforce error class name (not message body).

Dependency telemetry to Salesforce: host + operation name only. No request/response bodies. No auth headers.

### §8.6 Session security

- Session timeout: 30-minute idle (existing org setting; verify in Phase 0).
- "Lock sessions to the domain in which they were first used": enable.
- HTTPS-only cookies: enable.
- HSTS on `login.advancedrx.net`: enable at Front Door.

### §8.7 Logging hygiene

- No JWT or short-code contents to App Insights, `Error_Log__c`, or anywhere except the runtime call.
- Bridge-failure categories logged as enum codes, never as parsed exception messages.
- Interstitial page error UI shows generic "Sign-in failed" / "Link no longer valid" — never underlying state.
- Code bugs and unexpected exceptions go to App Insights (with scrubbed stack) — NOT to `MagicLinkLoginEvents` (which stays clean of internal state).
- AMPScript snippet in SFMC: no token or code values logged to SFMC tracking extensions or data extensions. Codes stay in HTTP request/response bodies only.

---

## §9 Testing and validation

### §9.1 Azure Function unit + integration tests

- Mint endpoint: happy path, oversized body, missing field, wrong channel, retPath rejection, collision retry, collision exhaustion, unresolved ContactId, idempotency replay.
- One-click bridge (`GET /r/{shortCode}`): happy path with `HttpClientFactory` mock for Salesforce; expired link (renders friendly expired page); unknown code; Salesforce token-exchange failure; Salesforce singleaccess failure. Assert `LastRedeemAtUtc` is updated on success without consuming the row (multi-use semantic). Assert `ScannerSuspected = true` flagging fires correctly for known scanner UA signatures.
- Every test method carries at least one outcome assertion. No coverage-inflation tests.

### §9.2 Salesforce-side Apex tests

**None for v1.** Salesforce-side surface is just the ECA record; no Apex to test.

If the optional admin-revoke tool ships post-launch: `MagicLinkAdminControllerTest` — admin-only access; happy path; failure path; `AuraHandledException` thrown on failure.

### §9.3 Integration tests in `ClaudeTest` + Azure dev environment

**End-to-end with a test SFMC sandbox:**

- Mint a link via the SFMC test send → verify the `/api/magic-links` mint call → tap link → assert authenticated session lands in the refill wizard in `ClaudeTest`.
- Click-after-expiry test: mint, wait 73 hours, tap, assert "this link has expired — please log in here" page with link to the regular DOB+code login.
- Multi-use test: mint, tap from browser A, complete a refill, tap again from browser B within the 72h window — assert second tap also lands logged in (multi-use semantic; no completion-based kill).
- Cross-domain test: confirm community Aura shell loads correctly post-frontdoor.
- Mobile test matrix (deferred research item per Phase 0 confirmation): iOS Safari, iOS Chrome, Android Chrome, Android Samsung Internet.

**Email channel:**

- Render a test email; click from each of Gmail web (Chrome desktop), Gmail iOS, Gmail Android, Outlook web, Outlook desktop, Apple Mail iOS, Apple Mail macOS.
- Microsoft Safe Links / Gmail link-checking test: send through a corporate-monitored mailbox; confirm scanner clicks generate `Bridge_Success` events with `ScannerSuspected = true` (where signatures match), confirm Sarah's subsequent real tap also succeeds (multi-use semantic holds), confirm scanner-induced sessions don't bleed beyond the scanner's HTTP client. Quantify the Salesforce-API volume cost per send for §10 Risk #17 headroom check.
- Forwarded-email test: forward a magic-link email; click from the second address. Confirm both clicks succeed (multi-use within the 72h window). Link continues to be valid for the full TTL regardless of how many devices have used it.

### §9.4 Load tests

- Estimate peak SFMC send rate (refill reminders cluster at known send times). Test mint endpoint at 10× expected burst.
- Test redeem endpoint at sustained patient-tap rates for the peak window.
- Confirm Salesforce token endpoint isn't the bottleneck under load (worth running once to confirm).

### §9.5 Synthetic monitoring (post-launch)

- Hourly canary contact in the cache. Function-internal mint + redeem cycle that exercises the full path without creating a real refill. Page on canary failure.

### §9.6 Security/penetration test items

- Code collision attack — attempt to mint with a code matching an existing live or expired row.
- IDOR — attempt to substitute another ContactId in the mint payload and observe whether mint succeeds for the wrong patient.
- URL tampering — modify the short code mid-flight.
- Open redirect via retPath — verify only allowlisted relative paths accepted.
- Timing-attack on hash comparison (constant-time compare).
- Header injection on the JWT exchange callout.
- Malformed JWT (algorithm confusion: `alg=none`, `alg=HS256` with public key as secret).
- Front Door bypass attempt — request the Function App directly without going through Front Door; confirm origin lockdown rejects.

---

## §10 Risks and open questions

| #   | Risk / question                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Owner                      | Resolution path                                                                                                                                                                                                                                                                                                                                                                                                                     |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | ~~Customer Community Plus Login license + singleaccess proof.~~                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | —                          | **✓ RESOLVED 2026-05-18.** Spike A passed in `ClaudeTest` — token exchange + singleaccess + frontdoor redirect + `sid` cookie all confirmed for the CCP Login license. Three implementation gotchas + a Phase 4 SetupEntityAccess wiring step added; see §6 Phase 0.                                                                                                                                                                |
| 2   | ~~SFMC link wrapping behavior — fragment vs path.~~                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | —                          | **✓ SOFT-RESOLVED 2026-05-21 via production SFMC self-send.** Test-send delivered fragment + path URLs unmodified to Gmail (no wrapping through `click.advancedrx.email`). Caveat: Test Sends often skip link wrapping; real journey-send behavior deferred to Phase 6a A/B rollout for definitive verification. V2 design accommodates either outcome. SFMC infra captured in §4.5; four execution gotchas captured in §6 Phase 0. |
| 3   | **SFMC credential rotation completion date.** V1 blocker still applies — building Azure-mediated auth on an SFMC integration with leaked credentials is bad sequencing.                                                                                                                                                                                                                                                                                                                                                                                                                                     | Marissa                    | Tracked in `MARISSA-HANDOFF.md` §2 #2; this project waits.                                                                                                                                                                                                                                                                                                                                                                          |
| 4   | **Acceptance of link-compromise risk per channel** (SMS SIM swap / family device; email shared inbox / forwarding / archived mailbox dumps / years-in-mailbox lifecycle). Note: link is multi-use within 72h with no completion-based kill, so the post-completion window of "someone with link access can order another refill of Sarah's existing prescriptions to her saved address" stays open until expiry. Bounded blast radius (refills only, saved address only); accepted as part of the simplification.                                                                                           | Kyle as compliance counsel | §5 prereq #5 — documented risk acceptance per channel.                                                                                                                                                                                                                                                                                                                                                                              |
| 5   | **retPath allowlist.** Validated at mint time against App Config. v1 ships with `/refill` only.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Lead dev                   | Resolved in §7.1 + §8 controls. Adding new paths is a deploy + review.                                                                                                                                                                                                                                                                                                                                                              |
| 6   | **Cert lifespan changes (200-day max from March 2026, 47-day max by 2029).**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | Lead dev                   | Annual rotation cadence; runbook in §8.4; calendar reminders Phase 0.                                                                                                                                                                                                                                                                                                                                                               |
| 7   | **Test SFMC sandbox availability.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | Marissa                    | §5 prereq #3.                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 8   | **Patient population using outdated mobile browsers.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Kyle                       | §9.3 covers current iOS/Android; document graceful-failure UX for unsupported browsers. **Open research item** per V2 prep (was V1 §9.3 item, no new info).                                                                                                                                                                                                                                                                         |
| 9   | **Session-lock-to-domain setting** — verify enabled in `ClaudeTest`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | Lead dev                   | §8.6 — pre-Phase 1 verification.                                                                                                                                                                                                                                                                                                                                                                                                    |
| 10  | **Refill-wizard landing UX from magic-link session.** `Account_Sig__c` transient state could carry stale values from prior invocations.                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Lead dev                   | §6 Phase 4 verification step. Same risk regardless of V1 vs V2 architecture.                                                                                                                                                                                                                                                                                                                                                        |
| 11  | **Marketing Cloud Connect (`et4ae5`) managed-package upgrade risk during build window.** Different exposure than V1 — V2 doesn't depend on `GetJWTByKeyName`, just `HTTPPost2` + `JSONParseValue`. Lower risk than V1 but still nonzero.                                                                                                                                                                                                                                                                                                                                                                    | Lead dev                   | Pin SFMC sandbox to current `et4ae5` version through go-live; stage upgrades in sandbox first.                                                                                                                                                                                                                                                                                                                                      |
| 12  | ~~Specific Salesforce-side trigger event for the `/complete` webhook~~                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | —                          | **MOOT in revision 3.** Completion-based kill removed entirely; time-based 72h expiry handles kill. See §1 + §3.3. No Salesforce-side trigger event to wire.                                                                                                                                                                                                                                                                        |
| 13  | ~~Interstitial vs true one-tap UX decision~~                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | Kyle                       | **RESOLVED 2026-05-18 — one-click adopted.** Rationale + trade documented in §3.6. Reversible to interstitial via a single-handler change if the trade proves wrong in practice.                                                                                                                                                                                                                                                    |
| 14  | ~~Fallback if patient browser blocks JS~~                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | —                          | **MOOT under one-click design.** The `GET /r/{shortCode}` handler is server-side and responds with a 303 redirect; no JS required client-side.                                                                                                                                                                                                                                                                                      |
| 15  | **SFMC → Azure outbound auth pattern** — Entra OAuth client credentials preferred; HMAC fallback if SFMC can't do Entra. (Salesforce → Azure auth is moot in revision 3 — no Salesforce-side callouts to Azure in v1.)                                                                                                                                                                                                                                                                                                                                                                                      | Lead dev (Phase 0)         | §6 Phase 0.                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 16  | **DR posture** — single-region matches the rest of AdvancedRx's Azure infra. If Azure East US 2 is down, patients fall back to the existing DOB+code login (friction we're trying to remove, but not a lockout).                                                                                                                                                                                                                                                                                                                                                                                            | Kyle                       | §1 — accepted. True HA is a platform-wide investment, not magic-link-specific.                                                                                                                                                                                                                                                                                                                                                      |
| 17  | **Scanner-induced Salesforce API volume under one-click design.** Microsoft Safe Links, Gmail link checking, corporate gateways will GET `/r/{shortCode}` as part of inspection — each hit consumes one Salesforce token-exchange call + one singleaccess call. At an estimated 3–5 scanner GETs per real send × N daily reminders, this could be tens-to-hundreds of thousands of extra Salesforce API calls per day. Per-org Salesforce API limits are rolling-24h and large in absolute terms, but worth measuring once at A/B rollout 10% to confirm headroom and worth monitoring on an ongoing basis. | Lead dev                   | Quantify in Phase 6a A/B rollout window via `MagicLinkLoginEvents` counts (`Bridge_Success` with `ScannerSuspected = true` vs `false`). Add an alert if the ratio degrades or absolute scanner-volume grows materially. If headroom becomes a concern, the answer is reverting to the explicit interstitial (single-handler revision per §3.6) — not a full architecture change.                                                    |
| 18  | **Spike A residue in `ClaudeTest`** — deactivated portal user `005WJ00000TPgziYAD` + Contact `003WJ00000jWcJZYA0` ("MAGICLINK SPIKE") + Account `001WJ00000nSR2fYAG` ("SPIKE") couldn't be deleted because Salesforce treats portal-user Contact links as immutable. Inert (user is deactivated, can't log in) but visible in queries.                                                                                                                                                                                                                                                                      | —                          | Cosmetic. A future sandbox refresh from production would wipe these out automatically. If they linger, they're named "SPIKE" so any future reader recognizes them. Removal otherwise would require a Salesforce Support ticket to fully delete the portal user.                                                                                                                                                                     |

---

## §11 Pre-deploy checklist (per CLAUDE.md §8 + V2-specific)

Salesforce-side deploy is just the External Client App metadata record (no Apex in v1):

1. `sf project deploy validate` against `ClaudeTest`.
2. Target org confirmed as `ClaudeTest` via `mcp__Salesforce_DX__get_username`.
3. **V2-specific:** External Client App's permitted users restricted to the community member profile (`Advanced RX Customer Community Plus Login User`) only.
4. **V2-specific:** ECA public certificate matches the active Key Vault key version in Azure.

If the post-launch admin-revoke tool ever ships, add: code analyzer clean, RunLocalTests passing with outcome-asserting tests, sharing keywords explicit, zero hardcoded credentials, Azure Named Credentials configured.

Every deploy of the Azure-side components passes (separate pipeline in `advancedrx-document-integration` repo):

11. Function App tests pass (every test has outcome assertions).
12. Front Door WAF rules deployed and verified (route allowlist, method allowlist, rate limits).
13. Key Vault MI scoped to "Crypto User" on the signing key only — no `Get` permission for the private key.
14. SQL stored procedures cover all writes; no inline SQL in the Function App.
15. App Insights telemetry scrubbing rules verified against §8.5 catalog.
16. Synthetic monitoring canary deployed and green.

---

## §12 References

### Internal

- `CLAUDE.md` — org operating rules.
- `.claude/rules/apex.md` — Apex conventions.
- `.claude/rules/flow.md` — Flow conventions.
- `.claude/rules/project-plans.md` — plan authoring conventions.
- `business-context/integrations/sfmc.md` — SFMC surface area + the credential-leak hazard.
- `business-context/storefront-data-audit.md` — B2B storefront data map.
- `business-context/workflows/patient-texting-refill-reminders.md` — refill-reminder send pipeline (§4.1 channel gates).
- `business-context/objects/Refill.md` — refill domain object.
- `business-context/objects/Account_Sig.md` — refill-wizard transient scratch state.
- `MARISSA-HANDOFF.md` §2 #2 — SFMC credential rotation status.
- [`../magic-link-portal-login.md`](../magic-link-portal-login.md) — original Salesforce-only plan, retained as fallback record.

### External (Salesforce official)

- OAuth 2.0 JWT Bearer Flow for Server-to-Server Integration (Salesforce Help).
- `singleaccess` endpoint (Summer '24 release notes, Release 250; xcloud frontdoor_singleaccess.htm).
- Preauthorize Users for ECA Access (Salesforce Help: xcloud.preauth_user_app_access_through_eca.htm).
- External Client Apps overview (Spring '26 release notes).

### External (Azure / Microsoft)

- Azure Front Door tier comparison (learn.microsoft.com — front-door-cdn-comparison).
- Azure Front Door WAF overview (learn.microsoft.com — frontdoor/web-application-firewall).
- Azure Front Door rate limiting (learn.microsoft.com — web-application-firewall/afds/waf-front-door-rate-limit-configure).
- Azure Key Vault `CryptographyClient` reference.
- `Microsoft.IdentityModel.JsonWebTokens` package reference.

### Reference implementations consulted (community)

- `salesforceidentity/password-less-login` (GitHub) — Salesforce Identity team's reference.
- Nubessom Consulting magic-link blueprint (Nazim Aliyev, November 2023).

### Codex audit (companion document)

- Codex's architectural plan + Bucket 1/2/3 follow-up audit, conducted 2026-05-18 against `advancedrx-document-integration`. Findings folded inline into §3, §4, §7, §8.
- **Deviation from Codex's recommendation:** Codex recommended the explicit-interstitial design as the strictest landing-page posture. After evaluating the trade-off against industry-standard one-click magic-link UX (Slack, Substack, Medium, Notion) and the bounded blast radius of a scanner-created session (ephemeral, sandbox-bound, refill-only access to existing Sarah-owned prescriptions), Kyle chose the one-click design. See §3.6 for the full evaluation; the decision is reversible via a single-handler revision if the trade proves wrong in practice.
