Signed JWT (PKI)¶
With PKI authentication, no shared secret leaves your environment. Your HIS keeps an RSA private key and signs a short-lived launch assertion for each clinician session. Sahl AI keeps only your public key. It verifies each assertion's signature and exchanges it for a session token.
What you do with the assertion depends on your integration:
| Integration | Your steps |
|---|---|
| Embedded panel | Sign the assertion and send it to the panel with the Secure JWT handshake. The panel exchanges it for a session token. You do not call the exchange endpoint. |
| Streaming API | Sign the assertion, then exchange it for a session token from your backend. Open the WebSocket with the session token. |
sequenceDiagram
autonumber
participant HIS as Your HIS (backend)
participant Panel as Sahl AI panel
participant Sahl as Sahl AI API
HIS->>HIS: Sign assertion with private key<br/>(iss, email, exp, iat, jti, kid)
alt Embedded panel
HIS->>Panel: SAHL_IFRAME_INIT { token: assertion }
Panel->>Sahl: POST /auth/jwt-login
Sahl-->>Panel: 2-hour session token
else Streaming API
HIS->>Sahl: POST /auth/jwt-login
Sahl-->>HIS: 2-hour session token
HIS->>Sahl: Open WebSocket with the session token
end
Either way, Sahl AI looks up your public key by iss and kid, then checks the signature, the assertion's age and that its jti has not been used before.
Onboarding¶
Activation is a one-time exchange with the Sahl AI integration team:
- You generate an RSA key pair, 2048 bits or larger, and choose a key ID (
kid). - You send the public key as PEM (
-----BEGIN PUBLIC KEY-----) with itskid. Use a channel where its integrity can be verified. A public key is not secret, but a substituted one would let someone impersonate you. - Sahl AI sends you an Organization ID for each tenant (hospital or branch) and the expected
audvalue for each environment. - You test on staging using the validation checklist below.
One Organization ID per tenant
Each hospital or branch that needs its own data boundary gets its own Organization ID, which you send as iss. Confirm the branch list with Sahl AI before onboarding.
Assertion format¶
Header
Payload
{
"iss": "<ORGANIZATION_ID>",
"aud": "<AUDIENCE>",
"email": "physician@hospital.sa",
"iat": 1761980400,
"exp": 1761980700,
"jti": "9b0c1f7e-6a43-4c1e-9a55-2f0e8d1c7b42"
}
| Claim | Location | Required | Rule |
|---|---|---|---|
alg |
header | Yes | RS256. |
kid |
header | Yes | The key ID you registered. Needed for key rotation. |
iss |
payload | Yes | Your Organization ID, issued by Sahl AI. |
email |
payload | Yes | The signed-in clinician. Sahl AI links the session to this clinician, and creates their profile on first use if your organization allows it. |
aud |
payload | Yes | The audience value issued by Sahl AI for the environment. |
iat |
payload | Yes | Issue time in seconds. Must be within the last 5 minutes. |
exp |
payload | Yes | Expiry time in seconds. No more than 5 minutes after iat. |
jti |
payload | Yes | A unique value (a UUID) for each assertion. A jti that has been seen before is rejected. |
Sign one assertion per launch
Assertions are single-use launch credentials, not session tokens. Sign a new one each time the clinician opens Sahl AI. Do not cache or reuse them. Keep the server clock NTP-synchronised: assertions older than five minutes are rejected.
Signing examples¶
var key = new RsaSecurityKey(rsa) { KeyId = "his-2026-01" };
var now = DateTime.UtcNow;
var token = new JwtSecurityToken(
issuer: orgId,
audience: audience,
claims: new[] {
new Claim("email", clinicianEmail),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
},
notBefore: null,
expires: now.AddMinutes(5),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.RsaSha256));
token.Payload["iat"] = new DateTimeOffset(now).ToUnixTimeSeconds();
var assertion = new JwtSecurityTokenHandler().WriteToken(token);
Exchange an assertion for a session token¶
Streaming API only
Skip this section if you only use the embedded panel. The panel calls this endpoint for you during the Secure JWT handshake.
POST https://medipulse-staging.sahl.ai/auth/jwt-login
Call this from your backend to get a session token for the Streaming API. Send the assertion as a bearer token:
curl -X POST 'https://medipulse-staging.sahl.ai/auth/jwt-login' \
-H 'Authorization: Bearer <SIGNED_ASSERTION>'
Or in the body:
Response 200 OK
The session token is valid for 2 hours and cannot be refreshed. Pass it as the second WebSocket subprotocol, as described in Streaming API. When it expires, sign a new assertion and exchange it again.
| Status | Meaning |
|---|---|
400 |
No assertion was supplied. |
401 |
The signature, issuer, age or jti check failed, or no key is registered for that iss/kid. |
Key rotation¶
- Generate a new key pair with a new
kidand send Sahl AI the public key. - Sahl AI registers it alongside the current key. Both are valid from then on.
- Switch your HIS to sign with the new key and
kid. - Ask Sahl AI to remove the old key once traffic has moved over.
If a private key may have been exposed, contact Sahl AI straight away so the key can be revoked.
Validation checklist¶
Run these against staging before you ask for production activation.
Embedded panel
- A valid assertion sent with the Secure JWT handshake opens the panel as the right clinician.
- Reloading the panel with the same assertion fails with
AUTH_ERROR. A newly signed assertion works. - An expired assertion, or one signed with an unregistered key, fails with
AUTH_ERROR.
Streaming API
- A valid assertion returns
200with a session token from/auth/jwt-login. - The session token opens a streaming session.
- An assertion with
expin the past is rejected. - Reusing an assertion (same
jti) is rejected. - An assertion signed with an unregistered key is rejected.
- An assertion carrying another tenant's
issis rejected.