You can call the Salesforce Health Cloud REST API from a React component. If you do, your OAuth token is in the browser. That is the whole problem.
“I spent a year wiring a Next.js application to Salesforce Health Cloud at a regulated healthcare client.”
The integration covered member records, compliance events, usage billing, and CRM sync - all of it touching data that cannot leak into a network tab. What follows are the three patterns I landed on after getting some of this wrong first. If you want to see what this kind of work looks like end to end, the AWS incident recovery case study shows the operational posture I bring to regulated environments.
Why Most Salesforce + Next.js Integrations Leak
The path of least resistance is dangerous here. You find jsforce on npm. You add your SF_USERNAME and SF_PASSWORD to your .env.local. You call conn.query() from a client component. It works in dev.
In production, that jsforce bundle ships to the browser. Your Salesforce credentials are in it. Anyone with DevTools can see them.
A third failure mode: you process Salesforce Platform Events in a webhook handler but skip the HMAC verification step because you only expect Salesforce to call you. An attacker who knows your endpoint can send arbitrary payloads and your code will write them to your database.
All three are fixable with the same underlying discipline: no Salesforce credential or health record should ever touch a code path that runs in the browser or that lacks authentication at the entry point.
Pattern 1: Server Component Data Fetching with OAuth Client Credentials
This is the read pattern. A user loads a page, and you need to pull their record from Health Cloud.
The right approach is to fetch from a Route Handler or a Server Component - never from a Client Component. The OAuth token lives in environment variables that Next.js never exposes to the client bundle, and the token request happens on the server during the request-response cycle.
Prerequisites:
- A Salesforce Connected App configured for OAuth 2.0 Client Credentials flow
SF_CLIENT_ID,SF_CLIENT_SECRET,SF_INSTANCE_URLin server-only environment variables (prefix with nothing; do not prefix withNEXT_PUBLIC_)- A Server Component or Route Handler that performs the data fetch
Step 1: Get a token on the server
// src/lib/salesforce.ts
async function getSFToken(): Promise<string> {
const params = new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.SF_CLIENT_ID!,
client_secret: process.env.SF_CLIENT_SECRET!,
});
const res = await fetch(
`${process.env.SF_INSTANCE_URL}/services/oauth2/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
next: { revalidate: 3500 }, // token TTL is typically 3600s
}
);
if (!res.ok) throw new Error("SF token request failed");
const { access_token } = await res.json();
return access_token;
}
export async function getSFRecord(
sobjectType: string,
recordId: string
): Promise<Record<string, unknown>> {
const token = await getSFToken();
const res = await fetch(
`${process.env.SF_INSTANCE_URL}/services/data/v60.0/sobjects/${sobjectType}/${recordId}`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (!res.ok) throw new Error(`SF record fetch failed: ${res.status}`);
return res.json();
}
Step 2: Call it from a Server Component
// src/app/members/[id]/page.tsx
import { getSFRecord } from "@/lib/salesforce";
export default async function MemberPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const record = await getSFRecord("Contact", id);
// render with server-only data - nothing leaks to client
return <MemberDetail name={record.Name as string} />;
}
The revalidate: 3500 on the token fetch is key. Next.js caches the token response server-side between requests, so you are not generating a new OAuth token on every page load. You are reusing it until it is about to expire.
What this prevents: Your SF credentials never appear in any client bundle. Your network traffic analysis for a HIPAA audit shows a clean server-to-server request chain with no PHI in query strings or request URLs.
Pattern 2: Event-Driven CRM Sync via Server Actions
This is the write pattern. Something happens in your app - a member completes a compliance event, a billing period closes - and you need to write it back to Health Cloud.
Server Actions are the right primitive here. They run on the server, have access to server environment variables, and the client never sees the HTTP surface.
Prerequisites:
- Salesforce Connected App permissions to create/update the relevant objects
- A Server Action file with
"use server"at the top - An idempotency key strategy to handle retries without double-writing
Step 1: Write the Server Action
// src/app/actions/sync-compliance-event.ts
"use server";
import { getSFToken } from "@/lib/salesforce";
import { db } from "@/lib/db";
export async function syncComplianceEvent(
memberId: string,
eventType: string,
occurredAt: Date
): Promise<{ success: boolean }> {
// Idempotency: check if this event was already synced
const existing = await db.sfSyncLog.findFirst({
where: { memberId, eventType, occurredAt },
});
if (existing?.syncedAt) {
return { success: true }; // already synced, not an error
}
const token = await getSFToken();
const payload = {
Member_ID__c: memberId,
Event_Type__c: eventType,
Occurred_At__c: occurredAt.toISOString(),
};
const res = await fetch(
`${process.env.SF_INSTANCE_URL}/services/data/v60.0/sobjects/Compliance_Event__c`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}
);
if (!res.ok) {
throw new Error(`SF write failed: ${res.status}`);
}
const { id: sfId } = await res.json();
await db.sfSyncLog.create({
data: { memberId, eventType, occurredAt, sfId, syncedAt: new Date() },
});
return { success: true };
}
Step 2: Call it from a form or event handler
// src/components/compliance-checkin.tsx
"use client";
import { syncComplianceEvent } from "@/app/actions/sync-compliance-event";
export function ComplianceCheckin({ memberId }: { memberId: string }) {
async function handleSubmit() {
await syncComplianceEvent(memberId, "DAILY_CHECK", new Date());
}
return (
<button onClick={handleSubmit} type="button">
Log compliance event
</button>
);
}
The idempotency check is not optional in a healthcare context. Retries happen. Network hiccups happen. If your compliance event fires twice and creates two Salesforce records, your billing engine will charge a member twice. I know this because I built a usage-based billing engine that depended on exactly this kind of event stream being clean. If you are auditing an existing integration for this class of problem, the DTC Stack Audit covers a similar surface for ecommerce, and The Operator's Stack addresses the full compliance and integration surface for independent operators.
What this prevents: The write path has no browser-accessible HTTP surface. A bad actor cannot intercept an API call and inject a fake compliance event. The idempotency log gives you an audit trail that your compliance team can pull if a record is ever disputed.
Pattern 3: Secure Webhook Ingestion from Salesforce Outbound Messages
Salesforce can push data to you. When a record changes in Health Cloud - a member is updated, a case is closed - Salesforce Outbound Messages will POST to your endpoint. This is useful. It is also an attack surface.
Salesforce Outbound Messages send SOAP/XML. They include a verification header. If you do not validate that header, anyone who discovers your endpoint URL can send arbitrary payloads.
Prerequisites:
- A Salesforce Workflow Rule or Flow configured to send Outbound Messages on the target object
- Your endpoint URL registered in the Outbound Message definition
- The Salesforce Organization ID (used in the SOAP body as the sender identifier)
Step 1: Write the Route Handler with SOAP parsing and validation
// src/app/api/salesforce/outbound/route.ts
import { NextRequest, NextResponse } from "next/server";
import { XMLParser } from "fast-xml-parser";
import { db } from "@/lib/db";
const parser = new XMLParser({ ignoreAttributes: false });
export async function POST(req: NextRequest): Promise<NextResponse> {
const body = await req.text();
// Parse the SOAP envelope
let parsed: Record<string, unknown>;
try {
parsed = parser.parse(body);
} catch {
return NextResponse.json({ error: "Invalid XML" }, { status: 400 });
}
// Extract the OrganizationId from the SOAP body to verify sender
const orgId =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(parsed as any)?.["soapenv:Envelope"]?.["soapenv:Body"]
?.notifications?.OrganizationId;
if (orgId !== process.env.SF_ORG_ID) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Extract notification objects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawNotifications = (parsed as any)?.["soapenv:Envelope"]
?.["soapenv:Body"]?.notifications?.Notification;
const notifications = Array.isArray(rawNotifications)
? rawNotifications
: [rawNotifications];
for (const notification of notifications) {
const sfObject = notification?.sObject;
if (!sfObject) continue;
const recordId: string = sfObject?.Id;
const idempotencyKey = `${recordId}-${notification.Id}`;
const alreadyProcessed = await db.sfInboundLog.findUnique({
where: { idempotencyKey },
});
if (!alreadyProcessed) {
// Process the record update
await db.memberRecord.upsert({
where: { sfId: recordId },
create: { sfId: recordId, ...mapSFFields(sfObject) },
update: { ...mapSFFields(sfObject) },
});
await db.sfInboundLog.create({ data: { idempotencyKey } });
}
}
// Salesforce requires an Ack response in SOAP format
return new NextResponse(
`<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<notificationsResponse xmlns="http://soap.sforce.com/2005/09/outbound">
<Ack>true</Ack>
</notificationsResponse>
</soapenv:Body>
</soapenv:Envelope>`,
{
status: 200,
headers: { "Content-Type": "text/xml" },
}
);
}
function mapSFFields(
sfObject: Record<string, unknown>
): Record<string, unknown> {
// Map Salesforce field names to your schema
return {
name: sfObject["Name"],
status: sfObject["Status__c"],
updatedAt: new Date(),
};
}
The Ack: true response is required. If Salesforce does not receive it, it retries the outbound message. Without idempotency handling, those retries create duplicate records. With it, they are no-ops.
What this prevents: Arbitrary POST injection by anyone who discovers the endpoint. The Organization ID check is not cryptographic proof - a motivated attacker could spoof the SOAP body - but it stops the casual probe. For stricter environments, add an IP allowlist for Salesforce's published outbound message IP ranges.
What Each Pattern Protects Against
The read pattern and write pattern together handle roughly 80% of the integration surface. The webhook pattern covers the remainder: the case where Salesforce needs to push changes to you rather than you pulling on a schedule.
Common Mistakes That Break HIPAA Audit Trails
Hard-coded credentials without rotation. If your SF credentials are in .env.production and you never rotate them, a credential leak (from a log file, a debug dump, a misconfigured error tracker) is permanent. I documented a real version of this problem in the cloud operations case study, where a credential propagation error took an entire integration layer offline. The client credentials OAuth flow lets you rotate the Connected App secret without changing code. Do that on a schedule.
Missing access logging on the CRM sync route. Your Route Handlers and Server Actions that touch Health Cloud data should log who triggered them and when. Not the PHI itself - just the actor and timestamp. That log is what your compliance team will ask for in an audit. If it does not exist, you are guessing about your own access history.
Client-side fetches with server-side data. The failure mode looks like this: you have a Server Component that fetches a member record correctly, then passes the full record object as a prop to a Client Component for display. If the record includes fields you did not intend to display, those fields are now serialized in the RSC payload and visible in the browser's network tab. Pass only the fields you need.
Skipping signature or identity verification on webhooks. The Organization ID check in Pattern 3 is a minimum floor. If your threat model includes sophisticated attackers, add IP range filtering for Salesforce's outbound message source IPs, which Salesforce publishes and updates periodically.
FAQ
Can I use jsforce with these patterns?
Yes, but only in server-only code. Import jsforce in a file that is never imported by a Client Component. If you use the App Router, put it in src/lib/ and never call it from a "use client" file. The connection setup is similar to what I showed above but the jsforce API is more ergonomic for complex SOQL queries. The same server-only discipline applies regardless of whether you use jsforce or raw fetch.
How do I handle Salesforce token expiry under load?
The next: { revalidate: 3500 } option on the token fetch caches the token in Next.js's data cache for just under an hour. Under high load, multiple simultaneous requests will all hit the cache rather than racing to generate new tokens. If you are running outside Next.js's caching layer (a background job, a queue worker), implement a short-lived in-memory cache with a mutex - or use a shared cache like Redis with a TTL that gives you a 60-second buffer before the actual expiry.
Do Server Actions protect against CSRF?
Yes. Next.js Server Actions include built-in CSRF protection starting in Next.js 14. The framework validates the Origin header against the host. For the patterns in this article, you do not need to add separate CSRF tokens. That said, if you are exposing the same logic through a Route Handler (for non-form invocations), you need to add your own origin check.
What about Salesforce Platform Events vs. Outbound Messages?
Platform Events are the modern Salesforce approach: they use a pub/sub model, and you consume them via the Streaming API or a webhook service like Heroku Connect. Outbound Messages are older - they use SOAP and fire from Workflow Rules. Both work. I used Outbound Messages at the regulated healthcare client because the Salesforce environment was already using Workflow Rules and the engineering lift to switch to Platform Events was not justified at the time. Platform Events are cleaner if you are starting fresh.
Is this enough for HIPAA compliance?
This is a technical baseline for not leaking PHI through your integration patterns. HIPAA compliance also requires a Business Associate Agreement with your cloud provider, access logging, workforce training, a risk analysis, and a set of administrative safeguards that have nothing to do with your code. The patterns here prevent a category of technical failure. They are one layer of a multi-layer requirement.
Sources and specifics
- OAuth 2.0 Client Credentials flow for server-to-server integrations is documented in Salesforce's Connected Apps and OAuth documentation (Salesforce Platform, v60.0 API reference).
- The idempotency key pattern and the
sfSyncLogapproach were used in a production Next.js application at a regulated healthcare client in Q1 2025. - Outbound Message verification via Organization ID is the minimum validation step documented in Salesforce's Outbound Messaging Developer Guide. IP allowlisting uses Salesforce's published list of outbound IP ranges.
- The token revalidation approach (
next: { revalidate: 3500 }) uses Next.js's fetch caching, available in the App Router (Next.js 13+). Tested against Next.js 15 and Next.js 16. - No PHI or identifiable member data is included in the code samples above. All field names are illustrative.
