Skip to main content

Partner SSO Integration Guide

Version: 1.0 Date: May 4, 2026 Audience: Engineers at Stealth Health white-label partners (clinics, brands, longevity / regenerative-medicine practices, prescriber networks) that operate a partner-owned application alongside the Stealth-hosted patient or prescriber portal. Status: Generally available

See also: Referral Tier Integration Guide for the lightweight integration where no PHI is shared, and Clinical Tier Integration Guide for the deeper integration where partner systems read patient profiles and prescriptions under a BAA.

Not what you want? If you're looking for inbound partner sign-in (your IdP authenticates users into the Stealth Health portal, rather than Stealth handing a token off to your hub), see White-Label OIDC Sign-in. The two features have separate configuration, separate host allowlists, and separate threat models.

1. What this enables

Stealth Health renders a configurable list of external links inside the left-hand sidenav of the patient and prescriber portals (the "Account" area). Each link is scoped to a specific user role (today: patient, doctor, prescriber) and can either:

  • Open the partner URL directly in a new tab, or
  • Route through a server-side SSO handoff that proves the user's identity to the partner without the patient or prescriber having to log in again.

The handoff is an OIDC-style redirect with a short-lived RS256 JWT. The partner verifies the token against a public JWKS Stealth publishes, and may then call a UserInfo endpoint to retrieve role-appropriate profile data.

This is the right integration when:

  • Your application is the place a Stealth-managed user already lives (e.g. a clinical hub used by prescribers Stealth has credentialed for you), and
  • You want a one-click handoff that does not require the user to type another password.

It is not the right integration for sharing patient PHI for clinical workflows. Use the Clinical Tier Integration Guide for that.


2. HIPAA boundaries and what we send

The handoff is deliberately narrow.

AudienceWhat the JWT carriesWhat /userinfo returns
Partner with patient linksub, partner_ref, email, email_verified, name, role, scopeIdentity only (sub, email, email_verified, name, partner_ref)
Partner with prescriber / doctor linkSame as aboveIdentity plus licensing metadata: npi, dea, primary state license (number, state, expiresAt), boardCertifications[], specialties[], practiceJurisdictions[]

We never include in the JWT or in /userinfo:

  • Patient diagnoses, medications, intake responses, lab results, vitals, appointment notes, payment information, addresses, DOB, MRN, or anything else flagged in our PHI denylist.
  • Cross-tenant data. The aud claim is bound to your audience string and we will reject any UserInfo call where the configured audience does not match.

Both sides defend against this:

  • The mint path filters claims through the same denylist before signing.
  • The UserInfo route runs every response through the denylist and returns 500 phi-leak-prevented rather than a payload that contains a forbidden key.

3. End-to-end flow


4. The handoff redirect

Stealth controls the start of the flow. You only need to host the callback URL that you give us during onboarding. We will register that URL (host only, exact path is yours) in your white-label SSO redirect allowlist. Any redirect to a host outside that allowlist is rejected before a token is minted.

When the user clicks the sidebar link, Stealth:

  1. Verifies the user's authenticated session.
  2. Resolves the white-label config for the host the user is on.
  3. Looks up the link by linkId, confirms it is a handoff link, and confirms the link's host is in your redirect allowlist.
  4. Mints an RS256 JWT (see §5).
  5. Writes a partner.sso.token.minted audit row.
  6. Returns a 302 to your callback URL with the token in the URL fragment (not the query string), under the key partner_token:
https://your.partner.host/sso/callback#partner_token=eyJhbGciOiJSUzI1NiIsImtpZCI6...

The fragment is intentional — it never reaches your server logs and is not sent in Referer headers when your callback page makes its own network requests.

Your callback should:

  1. Read the fragment with window.location.hash.
  2. Hand the token to your backend over a same-origin POST (or verify client-side if you are a SPA using a JWKS-aware library).
  3. Strip the fragment from the URL with history.replaceState so a browser-history copy of the URL no longer leaks the token.

5. Verifying the partner token

The token is a standard signed JWT.

Header

{ "alg": "RS256", "kid": "prod-rs256-20260420-3f2a", "typ": "JWT" }

Payload

{
"iss": "https://app.stealth.health",
"aud": "<your-audience>",
"sub": "<stealth-user-uid>",
"partner_ref": "<sha256(aud:sub)>",
"email": "user@example.com",
"email_verified": true,
"name": "Jane Prescriber",
"role": "prescriber",
"scope": "profile.read license.read",
"iat": 1746345600,
"nbf": 1746345600,
"exp": 1746345900,
"jti": "0c2b2d1e-2a4b-4d6f-8e2a-2d2b9e3a4f0a"
}

Verification rules — all required

RuleReason
alg === "RS256" exactly. Reject none, HS256, anything else.Defends against alg-substitution attacks.
Resolve the signing key by kid from the JWKS. Reject unknown kid.Forces partners to honour key rotation.
Verify the signature against the JWKS public key.The whole point.
iss matches the iss we publish in onboarding (https://app.stealth.health in prod).Cross-environment isolation.
aud matches the audience string we issued you.Cross-tenant isolation.
exp > now, nbf <= now, with at most ±60s clock skew.Tokens are 5 minutes max.
Track jti for the token's lifetime and reject reuse.Defends against token replay if a fragment leaks.
partner_ref is a stable per-partner pseudonym. Use it as your local user key — do not key on sub directly across audiences.Lets a single Stealth user appear as a different partner_ref to two different partners.

partner_ref is sha256("${aud}:${sub}") truncated to 32 hex chars. It is collision-resistant within your tenant and stable across sessions, so it is safe as the primary key for your local user record.

Reference: Node.js using jose

import {createRemoteJWKSet, jwtVerify} from 'jose';

const JWKS = createRemoteJWKSet(
new URL('https://app.stealth.health/.well-known/partner-jwks.json'),
);

export async function verifyStealthToken(token: string) {
const {payload, protectedHeader} = await jwtVerify(token, JWKS, {
issuer: 'https://app.stealth.health',
audience: 'regentherapy',
algorithms: ['RS256'],
clockTolerance: '60s',
});
return {payload, kid: protectedHeader.kid};
}

createRemoteJWKSet caches the JWKS for 5 minutes by default and performs cooperative refetch on a kid it has not seen, so you do not need to write your own cache.


6. The UserInfo endpoint

After verifying the token, you may exchange it for a richer profile.

GET https://app.stealth.health/api/partner/v1/userinfo
Authorization: Bearer <jwt>

The endpoint runs the same verification as above, plus a scope check (profile.read is required). The response is a JSON object whose shape depends on the user's role.

role: "patient"

{
"sub": "abc123",
"partner_ref": "0e1a...32hex",
"email": "patient@example.com",
"email_verified": true,
"name": "Pat Person",
"role": "patient"
}

role: "prescriber" or role: "doctor" (with license.read)

{
"sub": "abc123",
"partner_ref": "0e1a...32hex",
"email": "doc@example.com",
"email_verified": true,
"name": "Dr. Jane Prescriber",
"role": "prescriber",
"license": {
"npi": "1234567890",
"dea": "BJ1234563",
"primary": {
"number": "MD-12345",
"state": "MN",
"expiresAt": "2027-12-31T00:00:00.000Z"
},
"boardCertifications": ["Internal Medicine"],
"specialties": ["Regenerative Medicine"],
"practiceJurisdictions": ["MN", "WI", "IA"]
}
}

The endpoint deliberately omits keys whose absence is the answer (no phone, no address, no DOB, no patient roster, no Rx). If you have a need that is not satisfied here, talk to us — we will not satisfy it by adding fields to this endpoint.


7. Scopes

The scope claim is a space-delimited subset of:

ScopeWhat it allows
profile.readCalling /userinfo at all. Always required.
license.readReceiving the license block on /userinfo. Available only to prescriber and doctor.

A link's scopes array is a narrowing filter applied on top of the role-default scopes configured for your integration. You cannot widen scopes per link, only restrict them.


8. Errors and rate limits

UserInfo is rate-limited to 10 requests per minute keyed on the caller IP plus a token-prefix. You will receive 429 with a Retry-After header if you exceed it. Because verifications are cheap and the token is only valid for 5 minutes, partners do not normally hit this limit during a single login flow.

HTTPBody errorCause
400bad-requestMissing linkId, malformed token.
401missing-bearerNo Authorization: Bearer header on /userinfo.
401invalid-token / bad-signature / expired / not-yet-validVerification failed.
401wrong-issuer / wrong-audienceiss or aud mismatch.
401unknown-audienceaud is not configured for any white-label.
401replayjti was already used.
403role-not-allowedrole is outside {patient, doctor, prescriber}.
403missing-scopeprofile.read not granted.
429rate-limitedSlow down.
500phi-leak-preventedInternal — file a bug.

9. Key rotation

Stealth signs with a single active key (kid) and publishes both the active and the next key in JWKS during a rotation window. If you cache the JWKS, you should:

  1. Cache by kid, not by URL.
  2. On a kid you have not seen, refetch JWKS once (most JWKS clients, including jose's createRemoteJWKSet, do this for you).
  3. Tolerate seeing two keys in the set — both are valid.

We rotate at least annually and after any suspected compromise. Cached JWKS responses are served with Cache-Control: public, max-age=300, stale-while-revalidate=86400.


10. Environments

EnvironmentIssuerJWKSUserInfo
Productionhttps://app.stealth.healthhttps://app.stealth.health/.well-known/partner-jwks.jsonhttps://app.stealth.health/api/partner/v1/userinfo
Beta / staginghttps://beta.app.stealth.healthhttps://beta.app.stealth.health/.well-known/partner-jwks.jsonhttps://beta.app.stealth.health/api/partner/v1/userinfo

We can stand up a per-partner sandbox with synthetic users for integration testing on request.


11. Onboarding checklist

Email partners@stealth.health to start. The minimum we need from you to enable an SSO link is:

  1. Audience string you want to be issued (e.g. regentherapy).
  2. Allowed redirect host(s) — exact hostnames, not patterns. Must be HTTPS.
  3. Sidebar link spec(s) — for each role you want a link for:
    • Stable id (you will see this in audit metadata)
    • Display label
    • Callback URL
    • Optional icon name (any icon in lucide-react)
    • Optional scope-narrowing
  4. Verifier readiness — a callback that can read the URL fragment, verify the token against our JWKS, and call UserInfo. We have reference snippets for Node, Go, and Python.
  5. Rotation plan — confirm that your verifier resolves keys by kid (not by index) so we can rotate without breaking you.

Once we have those, we'll seed the configuration in beta first, give you a test prescriber, and promote to production after sign-off.