Skip to content

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:

  1. You generate an RSA key pair, 2048 bits or larger, and choose a key ID (kid).
  2. You send the public key as PEM (-----BEGIN PUBLIC KEY-----) with its kid. Use a channel where its integrity can be verified. A public key is not secret, but a substituted one would let someone impersonate you.
  3. Sahl AI sends you an Organization ID for each tenant (hospital or branch) and the expected aud value for each environment.
  4. 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

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "his-2026-01"
}

Payload

{
  "iss": "<ORGANIZATION_ID>",
  "aud": "<AUDIENCE>",
  "email": "[email protected]",
  "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

import jwt from "jsonwebtoken";
import { randomUUID } from "node:crypto";

const assertion = jwt.sign(
  { iss: ORG_ID, aud: AUDIENCE, email: clinician.email, jti: randomUUID() },
  PRIVATE_KEY_PEM,
  { algorithm: "RS256", keyid: "his-2026-01", expiresIn: "5m" } // sets iat + exp
);
import time, uuid, jwt  # PyJWT

now = int(time.time())
assertion = jwt.encode(
    {"iss": ORG_ID, "aud": AUDIENCE, "email": clinician_email,
     "iat": now, "exp": now + 300, "jti": str(uuid.uuid4())},
    PRIVATE_KEY_PEM,
    algorithm="RS256",
    headers={"kid": "his-2026-01"},
)
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:

{ "token": "<SIGNED_ASSERTION>" }

Response 200 OK

{
  "success": true,
  "token": "<SESSION_TOKEN>"
}

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

  1. Generate a new key pair with a new kid and send Sahl AI the public key.
  2. Sahl AI registers it alongside the current key. Both are valid from then on.
  3. Switch your HIS to sign with the new key and kid.
  4. 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 200 with a session token from /auth/jwt-login.
  • The session token opens a streaming session.
  • An assertion with exp in 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 iss is rejected.