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.
| Audience | What the JWT carries | What /userinfo returns |
|---|---|---|
Partner with patient link | sub, partner_ref, email, email_verified, name, role, scope | Identity only (sub, email, email_verified, name, partner_ref) |
Partner with prescriber / doctor link | Same as above | Identity 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
audclaim 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-preventedrather 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:
- Verifies the user's authenticated session.
- Resolves the white-label config for the host the user is on.
- Looks up the link by
linkId, confirms it is a handoff link, and confirms the link's host is in your redirect allowlist. - Mints an RS256 JWT (see §5).
- Writes a
partner.sso.token.mintedaudit row. - Returns a
302to your callback URL with the token in the URL fragment (not the query string), under the keypartner_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:
- Read the fragment with
window.location.hash. - 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).
- Strip the fragment from the URL with
history.replaceStateso 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
| Rule | Reason |
|---|---|
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:
| Scope | What it allows |
|---|---|
profile.read | Calling /userinfo at all. Always required. |
license.read | Receiving 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.
| HTTP | Body error | Cause |
|---|---|---|
400 | bad-request | Missing linkId, malformed token. |
401 | missing-bearer | No Authorization: Bearer header on /userinfo. |
401 | invalid-token / bad-signature / expired / not-yet-valid | Verification failed. |
401 | wrong-issuer / wrong-audience | iss or aud mismatch. |
401 | unknown-audience | aud is not configured for any white-label. |
401 | replay | jti was already used. |
403 | role-not-allowed | role is outside {patient, doctor, prescriber}. |
403 | missing-scope | profile.read not granted. |
429 | rate-limited | Slow down. |
500 | phi-leak-prevented | Internal — 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:
- Cache by
kid, not by URL. - On a
kidyou have not seen, refetch JWKS once (most JWKS clients, includingjose'screateRemoteJWKSet, do this for you). - 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
| Environment | Issuer | JWKS | UserInfo |
|---|---|---|---|
| Production | https://app.stealth.health | https://app.stealth.health/.well-known/partner-jwks.json | https://app.stealth.health/api/partner/v1/userinfo |
| Beta / staging | https://beta.app.stealth.health | https://beta.app.stealth.health/.well-known/partner-jwks.json | https://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:
- Audience string you want to be issued (e.g.
regentherapy). - Allowed redirect host(s) — exact hostnames, not patterns. Must be HTTPS.
- 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
- Stable
- 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.
- 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.